feat: Add allowPrivateIPAddresses config option for internal networks

Adds a new configuration option `allowPrivateIPAddresses` that allows
OIDC provider URLs to use private IP addresses (10.x.x.x, 172.16-31.x.x,
192.168.x.x). This is useful for internal deployments where Keycloak or
other OIDC providers run on private networks without DNS resolution.

Security considerations:
- Loopback addresses (127.0.0.1, localhost, ::1) remain blocked
- Link-local addresses (169.254.x.x) remain blocked
- Default is false (secure by default)

Fixes #97
This commit is contained in:
2025-12-08 12:34:36 +00:00
parent c0c90f6bce
commit bbcde3ef9c
11 changed files with 294 additions and 103 deletions
+29 -25
View File
@@ -18,17 +18,18 @@ type ScopeFilter interface {
// Handler provides core authentication functionality for OIDC flows
type Handler struct {
logger Logger
enablePKCE bool
isGoogleProv func() bool
isAzureProv func() bool
clientID string
authURL string
issuerURL string
scopes []string
overrideScopes bool
scopeFilter ScopeFilter // NEW
scopesSupported []string // NEW - from provider metadata
logger Logger
enablePKCE bool
isGoogleProv func() bool
isAzureProv func() bool
clientID string
authURL string
issuerURL string
scopes []string
overrideScopes bool
scopeFilter ScopeFilter // NEW
scopesSupported []string // NEW - from provider metadata
allowPrivateIPAddresses bool // Allow private IP addresses in URLs (for internal networks)
}
// Logger interface for dependency injection
@@ -40,19 +41,20 @@ type Logger interface {
// NewAuthHandler creates a new Handler instance
func NewAuthHandler(logger Logger, enablePKCE bool, isGoogleProv, isAzureProv func() bool,
clientID, authURL, issuerURL string, scopes []string, overrideScopes bool,
scopeFilter ScopeFilter, scopesSupported []string) *Handler {
scopeFilter ScopeFilter, scopesSupported []string, allowPrivateIPAddresses bool) *Handler {
return &Handler{
logger: logger,
enablePKCE: enablePKCE,
isGoogleProv: isGoogleProv,
isAzureProv: isAzureProv,
clientID: clientID,
authURL: authURL,
issuerURL: issuerURL,
scopes: scopes,
overrideScopes: overrideScopes,
scopeFilter: scopeFilter, // NEW
scopesSupported: scopesSupported, // NEW
logger: logger,
enablePKCE: enablePKCE,
isGoogleProv: isGoogleProv,
isAzureProv: isAzureProv,
clientID: clientID,
authURL: authURL,
issuerURL: issuerURL,
scopes: scopes,
overrideScopes: overrideScopes,
scopeFilter: scopeFilter,
scopesSupported: scopesSupported,
allowPrivateIPAddresses: allowPrivateIPAddresses,
}
}
@@ -347,6 +349,7 @@ func (h *Handler) validateParsedURL(u *url.URL) error {
// validateHost validates a hostname for security and reachability.
// It prevents access to private networks and localhost addresses.
// When allowPrivateIPAddresses is enabled, private IP checks are skipped.
func (h *Handler) validateHost(host string) error {
if host == "" {
return fmt.Errorf("empty host")
@@ -361,7 +364,7 @@ func (h *Handler) validateHost(host string) error {
}
}
// Check for localhost variations
// Check for localhost variations (always blocked, even with allowPrivateIPAddresses)
localhostVariations := []string{
"localhost", "127.0.0.1", "::1", "0.0.0.0",
}
@@ -376,7 +379,8 @@ func (h *Handler) validateHost(host string) error {
if ip.IsLoopback() {
return fmt.Errorf("loopback IP not allowed: %s", host)
}
if ip.IsPrivate() {
// Skip private IP check if allowPrivateIPAddresses is enabled
if !h.allowPrivateIPAddresses && ip.IsPrivate() {
return fmt.Errorf("private IP not allowed: %s", host)
}
if ip.IsLinkLocalUnicast() {
+25 -25
View File
@@ -86,7 +86,7 @@ func TestAuthHandler_NewAuthHandler(t *testing.T) {
handler := NewAuthHandler(logger, true, isGoogleProv, isAzureProv,
"test-client-id", "https://example.com/auth", "https://example.com",
scopes, false, nil, nil)
scopes, false, nil, nil, false)
if handler == nil {
t.Fatal("Expected handler to be created, got nil")
@@ -125,7 +125,7 @@ func TestAuthHandler_NewAuthHandler(t *testing.T) {
func TestAuthHandler_InitiateAuthentication_MaxRedirects(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil)
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, false)
session := &mockSessionData{redirectCount: 5} // At the limit
req := httptest.NewRequest("GET", "/test", nil)
@@ -160,7 +160,7 @@ func TestAuthHandler_InitiateAuthentication_MaxRedirects(t *testing.T) {
func TestAuthHandler_InitiateAuthentication_NonceGenerationError(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil)
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, false)
session := &mockSessionData{}
req := httptest.NewRequest("GET", "/test", nil)
@@ -191,7 +191,7 @@ func TestAuthHandler_InitiateAuthentication_NonceGenerationError(t *testing.T) {
func TestAuthHandler_InitiateAuthentication_PKCECodeVerifierError(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, true, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil)
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, false)
session := &mockSessionData{}
req := httptest.NewRequest("GET", "/test", nil)
@@ -222,7 +222,7 @@ func TestAuthHandler_InitiateAuthentication_PKCECodeVerifierError(t *testing.T)
func TestAuthHandler_InitiateAuthentication_PKCECodeChallengeError(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, true, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil)
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, false)
session := &mockSessionData{}
req := httptest.NewRequest("GET", "/test", nil)
@@ -253,7 +253,7 @@ func TestAuthHandler_InitiateAuthentication_PKCECodeChallengeError(t *testing.T)
func TestAuthHandler_InitiateAuthentication_SessionSaveError(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil)
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, false)
session := &mockSessionData{saveError: &testError{"save failed"}}
req := httptest.NewRequest("GET", "/test?param=value", nil)
@@ -297,7 +297,7 @@ func TestAuthHandler_InitiateAuthentication_SessionSaveError(t *testing.T) {
func TestAuthHandler_InitiateAuthentication_Success(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, true, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{"openid", "email"}, false, nil, nil)
"test-client", "https://example.com/auth", "https://example.com", []string{"openid", "email"}, false, nil, nil, false)
session := &mockSessionData{}
req := httptest.NewRequest("GET", "/protected/resource", nil)
@@ -400,7 +400,7 @@ func TestAuthHandler_BuildAuthURL_GoogleProvider(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return true }, func() bool { return false },
"google-client", "https://accounts.google.com/oauth2/auth", "https://accounts.google.com",
[]string{"openid", "profile", "email"}, false, nil, nil)
[]string{"openid", "profile", "email"}, false, nil, nil, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
@@ -440,7 +440,7 @@ func TestAuthHandler_BuildAuthURL_AzureProvider(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return true },
"azure-client", "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize",
"https://login.microsoftonline.com/tenant/v2.0",
[]string{"openid", "profile", "email"}, false, nil, nil)
[]string{"openid", "profile", "email"}, false, nil, nil, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
@@ -468,7 +468,7 @@ func TestAuthHandler_BuildAuthURL_PKCEEnabled(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, true, func() bool { return false }, func() bool { return false },
"pkce-client", "https://example.com/auth", "https://example.com",
[]string{"openid"}, false, nil, nil)
[]string{"openid"}, false, nil, nil, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "test-challenge")
@@ -493,7 +493,7 @@ func TestAuthHandler_BuildAuthURL_PKCEDisabled(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"no-pkce-client", "https://example.com/auth", "https://example.com",
[]string{"openid"}, false, nil, nil)
[]string{"openid"}, false, nil, nil, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "test-challenge")
@@ -565,7 +565,7 @@ func TestAuthHandler_BuildAuthURL_ScopeHandling(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return tt.isAzure },
"test-client", "https://example.com/auth", "https://example.com",
tt.scopes, tt.overrideScopes, nil, nil)
tt.scopes, tt.overrideScopes, nil, nil, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
@@ -634,7 +634,7 @@ func TestAuthHandler_BuildAuthURL_WithScopeFiltering(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com",
scopes, false, scopeFilter, scopesSupported)
scopes, false, scopeFilter, scopesSupported, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
@@ -676,7 +676,7 @@ func TestAuthHandler_BuildAuthURL_WithoutScopeFiltering(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com",
scopes, false, nil, nil)
scopes, false, nil, nil, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
@@ -714,7 +714,7 @@ func TestAuthHandler_BuildAuthURL_GitLabFiltersOfflineAccess(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"gitlab-client", "https://gitlab.example.com/oauth/authorize",
"https://gitlab.example.com",
scopes, false, scopeFilter, scopesSupported)
scopes, false, scopeFilter, scopesSupported, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
@@ -756,7 +756,7 @@ func TestAuthHandler_BuildAuthURL_GoogleRemovesOfflineAccess(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return true }, func() bool { return false },
"google-client", "https://accounts.google.com/o/oauth2/v2/auth",
"https://accounts.google.com",
scopes, false, scopeFilter, scopesSupported)
scopes, false, scopeFilter, scopesSupported, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
@@ -797,7 +797,7 @@ func TestAuthHandler_BuildAuthURL_AzureAddsOfflineAccess(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return true },
"azure-client", "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize",
"https://login.microsoftonline.com/tenant/v2.0",
scopes, false, scopeFilter, scopesSupported)
scopes, false, scopeFilter, scopesSupported, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
@@ -831,7 +831,7 @@ func TestAuthHandler_BuildAuthURL_GenericWithFiltering(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"generic-client", "https://auth.provider.com/authorize",
"https://auth.provider.com",
scopes, false, scopeFilter, scopesSupported)
scopes, false, scopeFilter, scopesSupported, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
@@ -870,7 +870,7 @@ func TestAuthHandler_BuildAuthURL_OverrideScopesWithFiltering(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com",
scopes, true, scopeFilter, scopesSupported)
scopes, true, scopeFilter, scopesSupported, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
@@ -916,7 +916,7 @@ func TestAuthHandler_BuildAuthURL_DoubleFiltering(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com",
scopes, false, scopeFilter, scopesSupported)
scopes, false, scopeFilter, scopesSupported, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
@@ -955,7 +955,7 @@ func TestAuthHandler_BuildAuthURL_NoScopeFilterProvided(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com",
scopes, false, nil, scopesSupported) // scopeFilter is nil
scopes, false, nil, scopesSupported, false) // scopeFilter is nil
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
@@ -988,7 +988,7 @@ func TestAuthHandler_BuildAuthURL_EmptyScopesSupported(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com",
scopes, false, scopeFilter, scopesSupported)
scopes, false, scopeFilter, scopesSupported, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
@@ -1021,7 +1021,7 @@ func TestAuthHandler_BuildAuthURL_FilteringWithPKCE(t *testing.T) {
handler := NewAuthHandler(logger, true, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com",
scopes, false, scopeFilter, scopesSupported)
scopes, false, scopeFilter, scopesSupported, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "test-challenge")
@@ -1064,7 +1064,7 @@ func TestAuthHandler_BuildAuthURL_ComplexScenario(t *testing.T) {
handler := NewAuthHandler(logger, true, func() bool { return false }, func() bool { return false },
"complex-client", "https://auth.complex.com/authorize", "https://auth.complex.com",
scopes, false, scopeFilter, scopesSupported)
scopes, false, scopeFilter, scopesSupported, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "state-123", "nonce-456", "challenge-789")
@@ -1130,7 +1130,7 @@ func TestAuthHandler_BuildAuthURL_LoggingVerification(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com",
scopes, false, scopeFilter, scopesSupported)
scopes, false, scopeFilter, scopesSupported, false)
handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
+103 -5
View File
@@ -10,7 +10,7 @@ import (
func TestAuthHandler_validateURL(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil)
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, false)
tests := []struct {
name string
@@ -185,7 +185,7 @@ func TestAuthHandler_validateURL(t *testing.T) {
func TestAuthHandler_validateHost(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil)
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, false)
tests := []struct {
name string
@@ -334,7 +334,7 @@ func TestAuthHandler_validateHost(t *testing.T) {
func TestAuthHandler_buildURLWithParams(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil)
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, false)
tests := []struct {
name string
@@ -438,7 +438,7 @@ func TestAuthHandler_buildURLWithParams(t *testing.T) {
func TestAuthHandler_buildURLWithParams_ParameterEncoding(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil)
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, false)
// Test special characters that need encoding
params := url.Values{
@@ -477,7 +477,7 @@ func TestAuthHandler_buildURLWithParams_ParameterEncoding(t *testing.T) {
func TestAuthHandler_validateParsedURL(t *testing.T) {
logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil)
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, false)
tests := []struct {
name string
@@ -560,3 +560,101 @@ func TestAuthHandler_validateParsedURL(t *testing.T) {
})
}
}
// TestAuthHandler_validateHost_AllowPrivateIPAddresses tests the allowPrivateIPAddresses flag
func TestAuthHandler_validateHost_AllowPrivateIPAddresses(t *testing.T) {
logger := &mockLogger{}
// Test with allowPrivateIPAddresses = false (default)
t.Run("Private IPs blocked by default", func(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, false)
privateIPs := []string{
"192.168.1.1",
"10.0.0.1",
"172.16.0.1",
"172.31.255.255",
}
for _, ip := range privateIPs {
err := handler.validateHost(ip)
if err == nil {
t.Errorf("Expected private IP %s to be blocked, but it was allowed", ip)
}
if err != nil && !strings.Contains(err.Error(), "private IP not allowed") {
t.Errorf("Expected 'private IP not allowed' error for %s, got: %v", ip, err)
}
}
})
// Test with allowPrivateIPAddresses = true
t.Run("Private IPs allowed when flag enabled", func(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, true)
privateIPs := []string{
"192.168.1.1",
"10.0.0.1",
"172.16.0.1",
"172.31.255.255",
}
for _, ip := range privateIPs {
err := handler.validateHost(ip)
if err != nil {
t.Errorf("Expected private IP %s to be allowed with flag enabled, but got error: %v", ip, err)
}
}
})
// Test that loopback is still blocked even with flag enabled
t.Run("Loopback always blocked", func(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, true)
loopbackAddresses := []string{
"127.0.0.1",
"localhost",
"::1",
"0.0.0.0",
}
for _, addr := range loopbackAddresses {
err := handler.validateHost(addr)
if err == nil {
t.Errorf("Expected loopback address %s to be blocked even with allowPrivateIPAddresses=true", addr)
}
}
})
// Test that link-local is still blocked even with flag enabled
t.Run("Link-local always blocked", func(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, true)
err := handler.validateHost("169.254.1.1")
if err == nil {
t.Error("Expected link-local address to be blocked even with allowPrivateIPAddresses=true")
}
})
// Test that public IPs work with flag enabled
t.Run("Public IPs allowed", func(t *testing.T) {
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", []string{}, false, nil, nil, true)
publicIPs := []string{
"8.8.8.8",
"1.1.1.1",
"142.250.185.68",
}
for _, ip := range publicIPs {
err := handler.validateHost(ip)
if err != nil {
t.Errorf("Expected public IP %s to be allowed, but got error: %v", ip, err)
}
}
})
}