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
+31
View File
@@ -120,6 +120,7 @@ testData:
allowOpaqueTokens: false # Enable opaque (non-JWT) access token support via RFC 7662 introspection allowOpaqueTokens: false # Enable opaque (non-JWT) access token support via RFC 7662 introspection
requireTokenIntrospection: false # Force introspection for opaque tokens (requires introspection endpoint) requireTokenIntrospection: false # Force introspection for opaque tokens (requires introspection endpoint)
disableReplayDetection: false # Disable JTI replay detection for multi-replica deployments (default: false) 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) # Security Headers Configuration (enabled by default with 'default' profile)
securityHeaders: securityHeaders:
@@ -266,6 +267,8 @@ testDataWithRedis:
# allowedRolesAndGroups: # Corresponds to 'Token Claim Name' in Keycloak mappers # allowedRolesAndGroups: # Corresponds to 'Token Claim Name' in Keycloak mappers
# - admin # - admin
# - editor # - 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. # # Ensure Keycloak client mappers add 'email', 'roles', 'groups' etc. to the ID Token.
# # See README.md "Provider Configuration Recommendations" for Keycloak. # # See README.md "Provider Configuration Recommendations" for Keycloak.
@@ -903,6 +906,34 @@ configuration:
Default: false (replay detection enabled) Default: false (replay detection enabled)
required: false 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: headers:
type: array type: array
description: | description: |
+14
View File
@@ -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 | | `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 | | `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` | | `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 | | `redis` | Redis cache configuration for distributed deployments | disabled | See "Redis Cache" section |
> **⚠️ IMPORTANT - TLS Termination at Load Balancer:** > **⚠️ IMPORTANT - TLS Termination at Load Balancer:**
@@ -1327,8 +1328,12 @@ spec:
- admin - admin
- editor - editor
# Ensure Keycloak client mappers add necessary claims to ID Token # 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 ### AWS Cognito Configuration
```yaml ```yaml
@@ -1862,6 +1867,15 @@ logLevel: debug
- No refresh tokens (re-authentication required on expiry) - No refresh tokens (re-authentication required on expiry)
- Use only for GitHub API access, not user authentication - 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: \<nil\>" 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 ### Provider Warnings and Recommendations
The middleware includes built-in warnings for provider-specific limitations. Check your logs for important notices about: The middleware includes built-in warnings for provider-specific limitations. Check your logs for important notices about:
+29 -25
View File
@@ -18,17 +18,18 @@ type ScopeFilter interface {
// Handler provides core authentication functionality for OIDC flows // Handler provides core authentication functionality for OIDC flows
type Handler struct { type Handler struct {
logger Logger logger Logger
enablePKCE bool enablePKCE bool
isGoogleProv func() bool isGoogleProv func() bool
isAzureProv func() bool isAzureProv func() bool
clientID string clientID string
authURL string authURL string
issuerURL string issuerURL string
scopes []string scopes []string
overrideScopes bool overrideScopes bool
scopeFilter ScopeFilter // NEW scopeFilter ScopeFilter // NEW
scopesSupported []string // NEW - from provider metadata scopesSupported []string // NEW - from provider metadata
allowPrivateIPAddresses bool // Allow private IP addresses in URLs (for internal networks)
} }
// Logger interface for dependency injection // Logger interface for dependency injection
@@ -40,19 +41,20 @@ type Logger interface {
// NewAuthHandler creates a new Handler instance // NewAuthHandler creates a new Handler instance
func NewAuthHandler(logger Logger, enablePKCE bool, isGoogleProv, isAzureProv func() bool, func NewAuthHandler(logger Logger, enablePKCE bool, isGoogleProv, isAzureProv func() bool,
clientID, authURL, issuerURL string, scopes []string, overrideScopes bool, clientID, authURL, issuerURL string, scopes []string, overrideScopes bool,
scopeFilter ScopeFilter, scopesSupported []string) *Handler { scopeFilter ScopeFilter, scopesSupported []string, allowPrivateIPAddresses bool) *Handler {
return &Handler{ return &Handler{
logger: logger, logger: logger,
enablePKCE: enablePKCE, enablePKCE: enablePKCE,
isGoogleProv: isGoogleProv, isGoogleProv: isGoogleProv,
isAzureProv: isAzureProv, isAzureProv: isAzureProv,
clientID: clientID, clientID: clientID,
authURL: authURL, authURL: authURL,
issuerURL: issuerURL, issuerURL: issuerURL,
scopes: scopes, scopes: scopes,
overrideScopes: overrideScopes, overrideScopes: overrideScopes,
scopeFilter: scopeFilter, // NEW scopeFilter: scopeFilter,
scopesSupported: scopesSupported, // NEW scopesSupported: scopesSupported,
allowPrivateIPAddresses: allowPrivateIPAddresses,
} }
} }
@@ -347,6 +349,7 @@ func (h *Handler) validateParsedURL(u *url.URL) error {
// validateHost validates a hostname for security and reachability. // validateHost validates a hostname for security and reachability.
// It prevents access to private networks and localhost addresses. // 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 { func (h *Handler) validateHost(host string) error {
if host == "" { if host == "" {
return fmt.Errorf("empty 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{ localhostVariations := []string{
"localhost", "127.0.0.1", "::1", "0.0.0.0", "localhost", "127.0.0.1", "::1", "0.0.0.0",
} }
@@ -376,7 +379,8 @@ func (h *Handler) validateHost(host string) error {
if ip.IsLoopback() { if ip.IsLoopback() {
return fmt.Errorf("loopback IP not allowed: %s", host) 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) return fmt.Errorf("private IP not allowed: %s", host)
} }
if ip.IsLinkLocalUnicast() { if ip.IsLinkLocalUnicast() {
+25 -25
View File
@@ -86,7 +86,7 @@ func TestAuthHandler_NewAuthHandler(t *testing.T) {
handler := NewAuthHandler(logger, true, isGoogleProv, isAzureProv, handler := NewAuthHandler(logger, true, isGoogleProv, isAzureProv,
"test-client-id", "https://example.com/auth", "https://example.com", "test-client-id", "https://example.com/auth", "https://example.com",
scopes, false, nil, nil) scopes, false, nil, nil, false)
if handler == nil { if handler == nil {
t.Fatal("Expected handler to be created, got 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) { func TestAuthHandler_InitiateAuthentication_MaxRedirects(t *testing.T) {
logger := &mockLogger{} logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false }, 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 session := &mockSessionData{redirectCount: 5} // At the limit
req := httptest.NewRequest("GET", "/test", nil) req := httptest.NewRequest("GET", "/test", nil)
@@ -160,7 +160,7 @@ func TestAuthHandler_InitiateAuthentication_MaxRedirects(t *testing.T) {
func TestAuthHandler_InitiateAuthentication_NonceGenerationError(t *testing.T) { func TestAuthHandler_InitiateAuthentication_NonceGenerationError(t *testing.T) {
logger := &mockLogger{} logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false }, 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{} session := &mockSessionData{}
req := httptest.NewRequest("GET", "/test", nil) req := httptest.NewRequest("GET", "/test", nil)
@@ -191,7 +191,7 @@ func TestAuthHandler_InitiateAuthentication_NonceGenerationError(t *testing.T) {
func TestAuthHandler_InitiateAuthentication_PKCECodeVerifierError(t *testing.T) { func TestAuthHandler_InitiateAuthentication_PKCECodeVerifierError(t *testing.T) {
logger := &mockLogger{} logger := &mockLogger{}
handler := NewAuthHandler(logger, true, func() bool { return false }, func() bool { return false }, 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{} session := &mockSessionData{}
req := httptest.NewRequest("GET", "/test", nil) req := httptest.NewRequest("GET", "/test", nil)
@@ -222,7 +222,7 @@ func TestAuthHandler_InitiateAuthentication_PKCECodeVerifierError(t *testing.T)
func TestAuthHandler_InitiateAuthentication_PKCECodeChallengeError(t *testing.T) { func TestAuthHandler_InitiateAuthentication_PKCECodeChallengeError(t *testing.T) {
logger := &mockLogger{} logger := &mockLogger{}
handler := NewAuthHandler(logger, true, func() bool { return false }, func() bool { return false }, 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{} session := &mockSessionData{}
req := httptest.NewRequest("GET", "/test", nil) req := httptest.NewRequest("GET", "/test", nil)
@@ -253,7 +253,7 @@ func TestAuthHandler_InitiateAuthentication_PKCECodeChallengeError(t *testing.T)
func TestAuthHandler_InitiateAuthentication_SessionSaveError(t *testing.T) { func TestAuthHandler_InitiateAuthentication_SessionSaveError(t *testing.T) {
logger := &mockLogger{} logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false }, 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"}} session := &mockSessionData{saveError: &testError{"save failed"}}
req := httptest.NewRequest("GET", "/test?param=value", nil) 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) { func TestAuthHandler_InitiateAuthentication_Success(t *testing.T) {
logger := &mockLogger{} logger := &mockLogger{}
handler := NewAuthHandler(logger, true, func() bool { return false }, func() bool { return false }, 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{} session := &mockSessionData{}
req := httptest.NewRequest("GET", "/protected/resource", nil) req := httptest.NewRequest("GET", "/protected/resource", nil)
@@ -400,7 +400,7 @@ func TestAuthHandler_BuildAuthURL_GoogleProvider(t *testing.T) {
logger := &mockLogger{} logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return true }, func() bool { return false }, handler := NewAuthHandler(logger, false, func() bool { return true }, func() bool { return false },
"google-client", "https://accounts.google.com/oauth2/auth", "https://accounts.google.com", "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", "") 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 }, handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return true },
"azure-client", "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize", "azure-client", "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize",
"https://login.microsoftonline.com/tenant/v2.0", "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", "") authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "")
@@ -468,7 +468,7 @@ func TestAuthHandler_BuildAuthURL_PKCEEnabled(t *testing.T) {
logger := &mockLogger{} logger := &mockLogger{}
handler := NewAuthHandler(logger, true, func() bool { return false }, func() bool { return false }, handler := NewAuthHandler(logger, true, func() bool { return false }, func() bool { return false },
"pkce-client", "https://example.com/auth", "https://example.com", "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") 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{} logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false }, handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"no-pkce-client", "https://example.com/auth", "https://example.com", "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") 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{} logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return tt.isAzure }, handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return tt.isAzure },
"test-client", "https://example.com/auth", "https://example.com", "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", "") 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 }, handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", "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", "") 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 }, handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", "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", "") 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 }, handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"gitlab-client", "https://gitlab.example.com/oauth/authorize", "gitlab-client", "https://gitlab.example.com/oauth/authorize",
"https://gitlab.example.com", "https://gitlab.example.com",
scopes, false, scopeFilter, scopesSupported) scopes, false, scopeFilter, scopesSupported, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "") 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 }, handler := NewAuthHandler(logger, false, func() bool { return true }, func() bool { return false },
"google-client", "https://accounts.google.com/o/oauth2/v2/auth", "google-client", "https://accounts.google.com/o/oauth2/v2/auth",
"https://accounts.google.com", "https://accounts.google.com",
scopes, false, scopeFilter, scopesSupported) scopes, false, scopeFilter, scopesSupported, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "") 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 }, handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return true },
"azure-client", "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize", "azure-client", "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize",
"https://login.microsoftonline.com/tenant/v2.0", "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", "") 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 }, handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"generic-client", "https://auth.provider.com/authorize", "generic-client", "https://auth.provider.com/authorize",
"https://auth.provider.com", "https://auth.provider.com",
scopes, false, scopeFilter, scopesSupported) scopes, false, scopeFilter, scopesSupported, false)
authURL := handler.BuildAuthURL("https://example.com/callback", "test-state", "test-nonce", "") 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 }, handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", "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", "") 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 }, handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", "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", "") 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 }, handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", "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", "") 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 }, handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", "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", "") 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 }, handler := NewAuthHandler(logger, true, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", "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") 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 }, handler := NewAuthHandler(logger, true, func() bool { return false }, func() bool { return false },
"complex-client", "https://auth.complex.com/authorize", "https://auth.complex.com", "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") 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 }, handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false },
"test-client", "https://example.com/auth", "https://example.com", "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", "") 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) { func TestAuthHandler_validateURL(t *testing.T) {
logger := &mockLogger{} logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false }, 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 { tests := []struct {
name string name string
@@ -185,7 +185,7 @@ func TestAuthHandler_validateURL(t *testing.T) {
func TestAuthHandler_validateHost(t *testing.T) { func TestAuthHandler_validateHost(t *testing.T) {
logger := &mockLogger{} logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false }, 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 { tests := []struct {
name string name string
@@ -334,7 +334,7 @@ func TestAuthHandler_validateHost(t *testing.T) {
func TestAuthHandler_buildURLWithParams(t *testing.T) { func TestAuthHandler_buildURLWithParams(t *testing.T) {
logger := &mockLogger{} logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false }, 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 { tests := []struct {
name string name string
@@ -438,7 +438,7 @@ func TestAuthHandler_buildURLWithParams(t *testing.T) {
func TestAuthHandler_buildURLWithParams_ParameterEncoding(t *testing.T) { func TestAuthHandler_buildURLWithParams_ParameterEncoding(t *testing.T) {
logger := &mockLogger{} logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false }, 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 // Test special characters that need encoding
params := url.Values{ params := url.Values{
@@ -477,7 +477,7 @@ func TestAuthHandler_buildURLWithParams_ParameterEncoding(t *testing.T) {
func TestAuthHandler_validateParsedURL(t *testing.T) { func TestAuthHandler_validateParsedURL(t *testing.T) {
logger := &mockLogger{} logger := &mockLogger{}
handler := NewAuthHandler(logger, false, func() bool { return false }, func() bool { return false }, 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 { tests := []struct {
name string 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)
}
}
})
}
+15
View File
@@ -437,6 +437,21 @@ http:
4. Configure client scopes and mappers 4. Configure client scopes and mappers
5. Generate client secret in Credentials tab 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 ## Okta
+52 -47
View File
@@ -15,20 +15,21 @@ import (
// XSS, path traversal, and other injection attacks. It validates and sanitizes // XSS, path traversal, and other injection attacks. It validates and sanitizes
// various input types used in OIDC authentication flows. // various input types used in OIDC authentication flows.
type InputValidator struct { type InputValidator struct {
usernameRegex *regexp.Regexp usernameRegex *regexp.Regexp
tokenRegex *regexp.Regexp tokenRegex *regexp.Regexp
logger *Logger logger *Logger
urlRegex *regexp.Regexp urlRegex *regexp.Regexp
emailRegex *regexp.Regexp emailRegex *regexp.Regexp
sqlInjectionPatterns []string sqlInjectionPatterns []string
pathTraversalPatterns []string pathTraversalPatterns []string
xssPatterns []string xssPatterns []string
maxUsernameLength int maxUsernameLength int
maxURLLength int maxURLLength int
maxTokenLength int maxTokenLength int
maxEmailLength int maxEmailLength int
maxClaimLength int maxClaimLength int
maxHeaderLength int maxHeaderLength int
allowPrivateIPAddresses bool // Allow private IP addresses in URL validation
} }
// ValidationResult encapsulates the outcome of input 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 // It specifies maximum lengths for various input types and controls whether
// strict validation mode is enabled. // strict validation mode is enabled.
type InputValidationConfig struct { type InputValidationConfig struct {
MaxTokenLength int `json:"max_token_length"` MaxTokenLength int `json:"max_token_length"`
MaxURLLength int `json:"max_url_length"` MaxURLLength int `json:"max_url_length"`
MaxHeaderLength int `json:"max_header_length"` MaxHeaderLength int `json:"max_header_length"`
MaxClaimLength int `json:"max_claim_length"` MaxClaimLength int `json:"max_claim_length"`
MaxEmailLength int `json:"max_email_length"` MaxEmailLength int `json:"max_email_length"`
MaxUsernameLength int `json:"max_username_length"` MaxUsernameLength int `json:"max_username_length"`
StrictMode bool `json:"strict_mode"` 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 // DefaultInputValidationConfig returns a secure default configuration
@@ -103,16 +105,17 @@ func NewInputValidator(config InputValidationConfig, logger *Logger) (*InputVali
} }
return &InputValidator{ return &InputValidator{
maxTokenLength: config.MaxTokenLength, maxTokenLength: config.MaxTokenLength,
maxURLLength: config.MaxURLLength, maxURLLength: config.MaxURLLength,
maxHeaderLength: config.MaxHeaderLength, maxHeaderLength: config.MaxHeaderLength,
maxClaimLength: config.MaxClaimLength, maxClaimLength: config.MaxClaimLength,
maxEmailLength: config.MaxEmailLength, maxEmailLength: config.MaxEmailLength,
maxUsernameLength: config.MaxUsernameLength, maxUsernameLength: config.MaxUsernameLength,
emailRegex: emailRegex, allowPrivateIPAddresses: config.AllowPrivateIPAddresses,
urlRegex: urlRegex, emailRegex: emailRegex,
tokenRegex: tokenRegex, urlRegex: urlRegex,
usernameRegex: usernameRegex, tokenRegex: tokenRegex,
usernameRegex: usernameRegex,
sqlInjectionPatterns: []string{ sqlInjectionPatterns: []string{
"'", "\"", ";", "--", "/*", "*/", "xp_", "sp_", "'", "\"", ";", "--", "/*", "*/", "xp_", "sp_",
"union", "select", "insert", "update", "delete", "drop", "union", "select", "insert", "update", "delete", "drop",
@@ -335,24 +338,26 @@ func (iv *InputValidator) ValidateURL(urlStr string) ValidationResult {
} }
} }
// Check for private IP ranges (RFC 1918) // Check for private IP ranges (RFC 1918) - skip if allowPrivateIPAddresses is enabled
if strings.HasPrefix(hostname, "10.") || if !iv.allowPrivateIPAddresses {
strings.HasPrefix(hostname, "192.168.") || if strings.HasPrefix(hostname, "10.") ||
strings.HasPrefix(hostname, "172.") { strings.HasPrefix(hostname, "192.168.") ||
// For 172.x check if it's in the 172.16.0.0/12 range strings.HasPrefix(hostname, "172.") {
if strings.HasPrefix(hostname, "172.") { // For 172.x check if it's in the 172.16.0.0/12 range
parts := strings.Split(hostname, ".") if strings.HasPrefix(hostname, "172.") {
if len(parts) >= 2 { parts := strings.Split(hostname, ".")
if second, err := strconv.Atoi(parts[1]); err == nil && second >= 16 && second <= 31 { if len(parts) >= 2 {
result.IsValid = false if second, err := strconv.Atoi(parts[1]); err == nil && second >= 16 && second <= 31 {
result.Errors = append(result.Errors, "private IP URLs are not allowed for security") result.IsValid = false
return result 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
} }
} }
+1
View File
@@ -218,6 +218,7 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
securityHeadersApplier: config.GetSecurityHeadersApplier(), securityHeadersApplier: config.GetSecurityHeadersApplier(),
scopeFilter: NewScopeFilter(logger), // NEW - for discovery-based scope filtering scopeFilter: NewScopeFilter(logger), // NEW - for discovery-based scope filtering
dcrConfig: config.DynamicClientRegistration, dcrConfig: config.DynamicClientRegistration,
allowPrivateIPAddresses: config.AllowPrivateIPAddresses,
} }
// Log audience configuration // Log audience configuration
+15
View File
@@ -131,6 +131,21 @@ type Config struct {
// When enabled, the middleware will automatically register as a client with // When enabled, the middleware will automatically register as a client with
// the OIDC provider if ClientID/ClientSecret are not provided. // the OIDC provider if ClientID/ClientSecret are not provided.
DynamicClientRegistration *DynamicClientRegistrationConfig `json:"dynamicClientRegistration,omitempty"` 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. // RedisConfig configures Redis cache backend settings for distributed caching.
+1
View File
@@ -128,6 +128,7 @@ type TraefikOidc struct {
suppressDiagnosticLogs bool suppressDiagnosticLogs bool
firstRequestReceived bool firstRequestReceived bool
metadataRefreshStarted bool metadataRefreshStarted bool
allowPrivateIPAddresses bool // Allow private IP addresses in URLs (for internal networks)
securityHeadersApplier func(http.ResponseWriter, *http.Request) securityHeadersApplier func(http.ResponseWriter, *http.Request)
scopeFilter *ScopeFilter // NEW - for discovery-based scope filtering scopeFilter *ScopeFilter // NEW - for discovery-based scope filtering
scopesSupported []string // NEW - from provider metadata scopesSupported []string // NEW - from provider metadata
+8 -1
View File
@@ -340,6 +340,7 @@ func (t *TraefikOidc) validateParsedURL(u *url.URL) error {
// validateHost validates a hostname or IP address for security. // validateHost validates a hostname or IP address for security.
// It prevents access to localhost, private networks, and known metadata endpoints. // It prevents access to localhost, private networks, and known metadata endpoints.
// When allowPrivateIPAddresses is enabled, private IP checks are skipped.
// Parameters: // Parameters:
// - host: The host string to validate (may include port). // - host: The host string to validate (may include port).
// //
@@ -357,7 +358,13 @@ func (t *TraefikOidc) validateHost(host string) error {
ip := net.ParseIP(hostname) ip := net.ParseIP(hostname)
if ip != nil { 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()) return fmt.Errorf("access to private/internal IP addresses is not allowed: %s", ip.String())
} }