mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-14 04:07:46 +00:00
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:
@@ -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: |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user