diff --git a/.traefik.yml b/.traefik.yml index 197c594..ddb18cc 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -120,6 +120,7 @@ testData: allowOpaqueTokens: false # Enable opaque (non-JWT) access token support via RFC 7662 introspection requireTokenIntrospection: false # Force introspection for opaque tokens (requires introspection endpoint) disableReplayDetection: false # Disable JTI replay detection for multi-replica deployments (default: false) + allowPrivateIPAddresses: false # Allow private IP addresses in provider URLs for internal networks (default: false) # Security Headers Configuration (enabled by default with 'default' profile) securityHeaders: @@ -266,6 +267,8 @@ testDataWithRedis: # allowedRolesAndGroups: # Corresponds to 'Token Claim Name' in Keycloak mappers # - admin # - editor +# # For internal Keycloak deployments with private IPs (Docker/Kubernetes internal): +# # allowPrivateIPAddresses: true # Enable for private IP addresses like 192.168.x.x, 10.x.x.x # # Ensure Keycloak client mappers add 'email', 'roles', 'groups' etc. to the ID Token. # # See README.md "Provider Configuration Recommendations" for Keycloak. @@ -903,6 +906,34 @@ configuration: Default: false (replay detection enabled) required: false + allowPrivateIPAddresses: + type: boolean + description: | + Allow private IP addresses in OIDC provider URLs for internal network deployments. + + By default, the plugin blocks URLs containing private IP address ranges + (10.x.x.x, 172.16-31.x.x, 192.168.x.x) to prevent SSRF attacks and ensure + OIDC providers are publicly accessible. + + Enable this option when: + - Your OIDC provider (e.g., Keycloak) runs on an internal network with private IPs + - You don't have DNS resolution available for internal services + - Your entire stack runs in a Docker network or Kubernetes cluster with private addressing + + When enabled, the plugin will accept provider URLs like: + - https://192.168.1.100:8443/auth/realms/your-realm + - https://10.0.0.50:8080/realms/master + - https://172.16.0.10/auth + + Security Warning: + Enabling this option reduces SSRF protection. Only use in trusted network + environments where the OIDC provider is known and controlled. Loopback + addresses (127.0.0.1, localhost, ::1) remain blocked even with this option enabled. + + Default: false (private IPs are blocked for security) + See: https://github.com/lukaszraczylo/traefikoidc/issues/97 + required: false + headers: type: array description: | diff --git a/README.md b/README.md index 2e97a2f..e369dc6 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ The middleware supports the following configuration options: | `headers` | Custom HTTP headers with templates that can access OIDC claims and tokens | none | See "Templated Headers" section | | `securityHeaders` | Configure security headers including CSP, HSTS, CORS, and custom headers | enabled with default profile | See "Security Headers Configuration" section | | `disableReplayDetection` | Disable JTI-based replay attack detection for multi-replica deployments | `false` | `true` | +| `allowPrivateIPAddresses` | Allow private IP addresses in provider URLs (for internal networks with Keycloak, etc.) | `false` | `true` | | `redis` | Redis cache configuration for distributed deployments | disabled | See "Redis Cache" section | > **⚠️ IMPORTANT - TLS Termination at Load Balancer:** @@ -1327,8 +1328,12 @@ spec: - admin - editor # Ensure Keycloak client mappers add necessary claims to ID Token + # For internal Keycloak deployments with private IPs (e.g., Docker network): + # allowPrivateIPAddresses: true ``` +> **Internal Network Deployment**: If your Keycloak runs on an internal network with private IP addresses (e.g., `192.168.x.x`, `10.x.x.x`, `172.16-31.x.x`) and you don't have DNS resolution available, set `allowPrivateIPAddresses: true` to allow the plugin to connect to your Keycloak instance. See [Issue #97](https://github.com/lukaszraczylo/traefikoidc/issues/97) for details. + ### AWS Cognito Configuration ```yaml @@ -1862,6 +1867,15 @@ logLevel: debug - No refresh tokens (re-authentication required on expiry) - Use only for GitHub API access, not user authentication +15. **Environment variable names containing "API" cause plugin failure** ([Issue #98](https://github.com/lukaszraczylo/traefikoidc/issues/98)): + - When using environment variable syntax like `${OIDC_ENCRYPTION_SECRET_API}` in Traefik configuration, the plugin fails with "invalid handler type: \" error + - This is a **Traefik-side issue**, not a plugin bug. Traefik uses reserved environment variables starting with `TRAEFIK_API_*` for its internal API configuration, and the "API" substring in user-defined variable names may interfere with Traefik's environment variable processing + - **Workaround**: Avoid using "API" as a substring in environment variable names. Use alternatives like: + - `${OIDC_ENCRYPTION_SECRET_SVC}` instead of `${OIDC_ENCRYPTION_SECRET_API}` + - `${OIDC_ENCRYPTION_SECRET_SERVICE}` + - `${OIDC_ENCRYPTION_SECRET_BACKEND}` + - Any name that doesn't contain the literal substring "API" + ### Provider Warnings and Recommendations The middleware includes built-in warnings for provider-specific limitations. Check your logs for important notices about: diff --git a/auth/auth_handler.go b/auth/auth_handler.go index 7ab5a7b..8e303e5 100644 --- a/auth/auth_handler.go +++ b/auth/auth_handler.go @@ -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() { diff --git a/auth/auth_handler_test.go b/auth/auth_handler_test.go index 974df40..1451db6 100644 --- a/auth/auth_handler_test.go +++ b/auth/auth_handler_test.go @@ -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", "") diff --git a/auth/url_validation_test.go b/auth/url_validation_test.go index db1d93f..d326524 100644 --- a/auth/url_validation_test.go +++ b/auth/url_validation_test.go @@ -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) + } + } + }) +} diff --git a/docs/PROVIDER_CONFIGURATIONS.md b/docs/PROVIDER_CONFIGURATIONS.md index e00a9d3..b0db1b8 100644 --- a/docs/PROVIDER_CONFIGURATIONS.md +++ b/docs/PROVIDER_CONFIGURATIONS.md @@ -437,6 +437,21 @@ http: 4. Configure client scopes and mappers 5. Generate client secret in Credentials tab +### Internal Network Deployment + +If your Keycloak instance runs on an internal network with private IP addresses (e.g., Docker networks, Kubernetes internal services), set `allowPrivateIPAddresses: true`: + +```yaml +traefikoidc: + providerUrl: "https://192.168.1.100:8443/auth/realms/your-realm" # Private IP + allowPrivateIPAddresses: true # Required for private IP addresses + clientId: "your-client-id" + clientSecret: "your-client-secret" + # ... other config +``` + +> **Security Warning**: Only enable `allowPrivateIPAddresses` in trusted network environments where you control the OIDC provider. This setting reduces SSRF protection. + --- ## Okta diff --git a/input_validation.go b/input_validation.go index 723cc05..d59eb40 100644 --- a/input_validation.go +++ b/input_validation.go @@ -15,20 +15,21 @@ import ( // XSS, path traversal, and other injection attacks. It validates and sanitizes // various input types used in OIDC authentication flows. type InputValidator struct { - usernameRegex *regexp.Regexp - tokenRegex *regexp.Regexp - logger *Logger - urlRegex *regexp.Regexp - emailRegex *regexp.Regexp - sqlInjectionPatterns []string - pathTraversalPatterns []string - xssPatterns []string - maxUsernameLength int - maxURLLength int - maxTokenLength int - maxEmailLength int - maxClaimLength int - maxHeaderLength int + usernameRegex *regexp.Regexp + tokenRegex *regexp.Regexp + logger *Logger + urlRegex *regexp.Regexp + emailRegex *regexp.Regexp + sqlInjectionPatterns []string + pathTraversalPatterns []string + xssPatterns []string + maxUsernameLength int + maxURLLength int + maxTokenLength int + maxEmailLength int + maxClaimLength int + maxHeaderLength int + allowPrivateIPAddresses bool // Allow private IP addresses in URL validation } // ValidationResult encapsulates the outcome of input validation. @@ -46,13 +47,14 @@ type ValidationResult struct { // It specifies maximum lengths for various input types and controls whether // strict validation mode is enabled. type InputValidationConfig struct { - MaxTokenLength int `json:"max_token_length"` - MaxURLLength int `json:"max_url_length"` - MaxHeaderLength int `json:"max_header_length"` - MaxClaimLength int `json:"max_claim_length"` - MaxEmailLength int `json:"max_email_length"` - MaxUsernameLength int `json:"max_username_length"` - StrictMode bool `json:"strict_mode"` + MaxTokenLength int `json:"max_token_length"` + MaxURLLength int `json:"max_url_length"` + MaxHeaderLength int `json:"max_header_length"` + MaxClaimLength int `json:"max_claim_length"` + MaxEmailLength int `json:"max_email_length"` + MaxUsernameLength int `json:"max_username_length"` + StrictMode bool `json:"strict_mode"` + AllowPrivateIPAddresses bool `json:"allow_private_ip_addresses"` // Allow private IP addresses in URL validation } // DefaultInputValidationConfig returns a secure default configuration @@ -103,16 +105,17 @@ func NewInputValidator(config InputValidationConfig, logger *Logger) (*InputVali } return &InputValidator{ - maxTokenLength: config.MaxTokenLength, - maxURLLength: config.MaxURLLength, - maxHeaderLength: config.MaxHeaderLength, - maxClaimLength: config.MaxClaimLength, - maxEmailLength: config.MaxEmailLength, - maxUsernameLength: config.MaxUsernameLength, - emailRegex: emailRegex, - urlRegex: urlRegex, - tokenRegex: tokenRegex, - usernameRegex: usernameRegex, + maxTokenLength: config.MaxTokenLength, + maxURLLength: config.MaxURLLength, + maxHeaderLength: config.MaxHeaderLength, + maxClaimLength: config.MaxClaimLength, + maxEmailLength: config.MaxEmailLength, + maxUsernameLength: config.MaxUsernameLength, + allowPrivateIPAddresses: config.AllowPrivateIPAddresses, + emailRegex: emailRegex, + urlRegex: urlRegex, + tokenRegex: tokenRegex, + usernameRegex: usernameRegex, sqlInjectionPatterns: []string{ "'", "\"", ";", "--", "/*", "*/", "xp_", "sp_", "union", "select", "insert", "update", "delete", "drop", @@ -335,24 +338,26 @@ func (iv *InputValidator) ValidateURL(urlStr string) ValidationResult { } } - // Check for private IP ranges (RFC 1918) - if strings.HasPrefix(hostname, "10.") || - strings.HasPrefix(hostname, "192.168.") || - strings.HasPrefix(hostname, "172.") { - // For 172.x check if it's in the 172.16.0.0/12 range - if strings.HasPrefix(hostname, "172.") { - parts := strings.Split(hostname, ".") - if len(parts) >= 2 { - if second, err := strconv.Atoi(parts[1]); err == nil && second >= 16 && second <= 31 { - result.IsValid = false - result.Errors = append(result.Errors, "private IP URLs are not allowed for security") - return result + // Check for private IP ranges (RFC 1918) - skip if allowPrivateIPAddresses is enabled + if !iv.allowPrivateIPAddresses { + if strings.HasPrefix(hostname, "10.") || + strings.HasPrefix(hostname, "192.168.") || + strings.HasPrefix(hostname, "172.") { + // For 172.x check if it's in the 172.16.0.0/12 range + if strings.HasPrefix(hostname, "172.") { + parts := strings.Split(hostname, ".") + if len(parts) >= 2 { + if second, err := strconv.Atoi(parts[1]); err == nil && second >= 16 && second <= 31 { + result.IsValid = false + result.Errors = append(result.Errors, "private IP URLs are not allowed for security") + return result + } } + } else { + result.IsValid = false + result.Errors = append(result.Errors, "private IP URLs are not allowed for security") + return result } - } else { - result.IsValid = false - result.Errors = append(result.Errors, "private IP URLs are not allowed for security") - return result } } diff --git a/main.go b/main.go index 398c8a5..a338774 100644 --- a/main.go +++ b/main.go @@ -218,6 +218,7 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name securityHeadersApplier: config.GetSecurityHeadersApplier(), scopeFilter: NewScopeFilter(logger), // NEW - for discovery-based scope filtering dcrConfig: config.DynamicClientRegistration, + allowPrivateIPAddresses: config.AllowPrivateIPAddresses, } // Log audience configuration diff --git a/settings.go b/settings.go index c22803d..9a2540d 100644 --- a/settings.go +++ b/settings.go @@ -131,6 +131,21 @@ type Config struct { // When enabled, the middleware will automatically register as a client with // the OIDC provider if ClientID/ClientSecret are not provided. DynamicClientRegistration *DynamicClientRegistrationConfig `json:"dynamicClientRegistration,omitempty"` + + // AllowPrivateIPAddresses disables the security check that blocks private/internal IP addresses. + // By default, the plugin rejects URLs containing private IP ranges (10.x.x.x, 172.16-31.x.x, 192.168.x.x) + // to prevent SSRF attacks and ensure OIDC providers are publicly accessible. + // + // Enable this option ONLY when: + // - Your OIDC provider (e.g., Keycloak) runs on an internal network with private IPs + // - You have no DNS resolution available for internal services + // - Your entire stack runs in a Docker network or Kubernetes cluster with private addressing + // + // Security Warning: Enabling this option reduces SSRF protection. Only use in trusted + // network environments where the OIDC provider is known and controlled. + // + // Default: false (private IPs are blocked for security) + AllowPrivateIPAddresses bool `json:"allowPrivateIPAddresses,omitempty"` } // RedisConfig configures Redis cache backend settings for distributed caching. diff --git a/types.go b/types.go index 0307661..ad66337 100644 --- a/types.go +++ b/types.go @@ -128,6 +128,7 @@ type TraefikOidc struct { suppressDiagnosticLogs bool firstRequestReceived bool metadataRefreshStarted bool + allowPrivateIPAddresses bool // Allow private IP addresses in URLs (for internal networks) securityHeadersApplier func(http.ResponseWriter, *http.Request) scopeFilter *ScopeFilter // NEW - for discovery-based scope filtering scopesSupported []string // NEW - from provider metadata diff --git a/url_helpers.go b/url_helpers.go index df19d34..f12e731 100644 --- a/url_helpers.go +++ b/url_helpers.go @@ -340,6 +340,7 @@ func (t *TraefikOidc) validateParsedURL(u *url.URL) error { // validateHost validates a hostname or IP address for security. // It prevents access to localhost, private networks, and known metadata endpoints. +// When allowPrivateIPAddresses is enabled, private IP checks are skipped. // Parameters: // - host: The host string to validate (may include port). // @@ -357,7 +358,13 @@ func (t *TraefikOidc) validateHost(host string) error { ip := net.ParseIP(hostname) if ip != nil { - if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + // Always block loopback, link-local, and multicast addresses + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return fmt.Errorf("access to loopback/link-local IP addresses is not allowed: %s", ip.String()) + } + + // Skip private IP check if allowPrivateIPAddresses is enabled + if !t.allowPrivateIPAddresses && ip.IsPrivate() { return fmt.Errorf("access to private/internal IP addresses is not allowed: %s", ip.String()) }