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:33:17 +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
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: |
+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 |
| `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: \<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
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
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)
}
}
})
}
+15
View File
@@ -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
+52 -47
View File
@@ -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
}
}
+1
View File
@@ -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
+15
View File
@@ -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.
+1
View File
@@ -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
+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.
// 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())
}