From 78b6f193b3da860509610a6aa48c7af0659296ac Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sun, 9 Aug 2026 00:47:25 +0100 Subject: [PATCH] fix(security): allow loopback hosts for loopback providerURL (#154) Issue #154: a providerURL that points at a localhost IdP caused an infinite redirect loop. validateHost rejected loopback hosts unconditionally, so buildAuthURL returned an empty string. Derive allowLoopbackHosts from a loopback providerURL at construction time. Discovery validation already used this derivation, so the two validation layers now agree. This adds no new config surface. Cloud metadata hosts (169.254.169.254, metadata.google.internal), link-local, and unspecified addresses stay blocked unconditionally. Non-loopback providers keep identical behaviour. Add issue154_regression_test.go: 8 tests pin the new allowance, the default block, and the security envelope. --- issue154_regression_test.go | 326 ++++++++++++++++++++++++++++++++++++ main.go | 11 +- settings.go | 12 ++ types.go | 171 ++++++++++--------- url_helpers.go | 27 ++- 5 files changed, 452 insertions(+), 95 deletions(-) create mode 100644 issue154_regression_test.go diff --git a/issue154_regression_test.go b/issue154_regression_test.go new file mode 100644 index 0000000..4b40672 --- /dev/null +++ b/issue154_regression_test.go @@ -0,0 +1,326 @@ +package traefikoidc + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// issue154_regression_test.go — regression tests for issue #154. +// +// A providerURL pointing at a localhost identity provider (local +// development, sidecar OIDC providers) caused an infinite redirect loop. +// validateHost unconditionally rejected loopback/localhost hosts, so +// buildURLWithParams returned "" for the discovered authorization endpoint, +// buildAuthURL returned "", and Traefik kept redirecting to an empty +// Location header forever. +// +// The fix adds an unexported `allowLoopbackHosts bool` field to TraefikOidc, +// derived at construction time from `isLoopbackProviderURL(config.ProviderURL)` +// (settings.go). validateHost now gates loopback IPs and localhost/127.x/::1 +// literals on `!t.allowLoopbackHosts`, while link-local, multicast, and the +// cloud-metadata hostnames stay unconditionally blocked (SSRF surface must +// not widen). updateMetadataEndpoints recomputes the same derivation from +// t.providerURL directly (not the struct field), so it stays correct for +// TraefikOidc values test code builds as struct literals. +// +// These tests pin: the opt-in behaves correctly, the default (flag unset) +// still blocks loopback exactly as before the fix, the SSRF-relevant hosts +// stay blocked even with the opt-in active, allowPrivateIPAddresses remains +// an independent gate, the derivation helper is correct in isolation, the +// end-to-end buildAuthURL behavior for both flag states, that NewWithContext +// derives the field correctly from config.ProviderURL, and that the +// discovery layer (updateMetadataEndpoints) and the URL-validation layer +// (validateHost/buildAuthURL) agree on a loopback provider. + +// issue154LoopbackHosts are host strings that represent localhost/loopback +// under every literal form validateHost must recognize: bare hostname, +// hostname:port, case variation, IPv4 loopback with and without port, an +// IPv6 loopback literal with port, and a non-canonical loopback IP +// (127.0.0.0/8 is entirely loopback, not just 127.0.0.1). +var issue154LoopbackHosts = []string{ + "localhost", + "localhost:8080", + "LOCALHOST", + "127.0.0.1", + "127.0.0.1:8080", + "[::1]:8080", + "127.0.0.2:9000", +} + +// TestIssue154ValidateHostLoopbackAllowed pins the opt-in: once a middleware +// instance has allowLoopbackHosts=true (as New/NewWithContext derive it from +// a loopback providerURL), every loopback host form validates cleanly. +func TestIssue154ValidateHostLoopbackAllowed(t *testing.T) { + middleware := createMinimalMiddleware() + middleware.allowLoopbackHosts = true + + for _, host := range issue154LoopbackHosts { + t.Run(host, func(t *testing.T) { + if err := middleware.validateHost(host); err != nil { + t.Fatalf("validateHost(%q) with allowLoopbackHosts=true must succeed, got: %v", host, err) + } + }) + } +} + +// TestIssue154ValidateHostLoopbackDefaultBlocked pins the default-preserving +// half of the fix: a middleware instance built without explicitly setting +// allowLoopbackHosts (its zero value, matching every TraefikOidc a +// non-loopback New()/NewWithContext call produces) must still reject every +// loopback host form, exactly as before the fix. This is asserted as +// "error present", not against exact error text: "[::1]:8080" alone +// (without a bracket-less counterpart) does not hit the pre-existing +// net.SplitHostPort quirk where a bare "[::1]" without a port fails with +// "invalid host format" instead of the loopback-specific message — but +// pinning only non-nil keeps this test robust to either error path. +func TestIssue154ValidateHostLoopbackDefaultBlocked(t *testing.T) { + middleware := createMinimalMiddleware() // allowLoopbackHosts left at zero value (false) + + for _, host := range issue154LoopbackHosts { + t.Run(host, func(t *testing.T) { + if err := middleware.validateHost(host); err == nil { + t.Fatalf("validateHost(%q) with default allowLoopbackHosts must still be rejected", host) + } + }) + } +} + +// TestIssue154SecurityEnvelopeWithLoopbackAllowed is the highest-value test +// in this file: it proves that opting into loopback hosts does not widen the +// SSRF surface. Cloud metadata addresses, link-local, multicast, and +// unspecified addresses must stay blocked even with allowLoopbackHosts=true. +func TestIssue154SecurityEnvelopeWithLoopbackAllowed(t *testing.T) { + middleware := createMinimalMiddleware() + middleware.allowLoopbackHosts = true + + cases := []struct { + name string + host string + wantSubstr string + }{ + {"AWS/GCP metadata IP", "169.254.169.254", "loopback/link-local"}, + {"AWS/GCP metadata IP with port", "169.254.169.254:80", "loopback/link-local"}, + {"GCP metadata hostname", "metadata.google.internal", "dangerous hostname"}, + {"GCP metadata hostname with port", "metadata.google.internal:80", "dangerous hostname"}, + {"IPv6 link-local", "[fe80::1]:80", "loopback/link-local"}, + {"IPv4 link-local multicast", "224.0.0.1", "loopback/link-local"}, + {"unspecified IPv4", "0.0.0.0", "unspecified or multicast"}, + {"unspecified IPv6 with port", "[::]:80", "unspecified or multicast"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := middleware.validateHost(tc.host) + if err == nil { + t.Fatalf("validateHost(%q) must still be rejected with allowLoopbackHosts=true", tc.host) + } + if !strings.Contains(err.Error(), tc.wantSubstr) { + t.Fatalf("validateHost(%q) error = %q, want substring %q", tc.host, err.Error(), tc.wantSubstr) + } + }) + } +} + +// TestIssue154PrivateIPIndependence proves allowLoopbackHosts and +// allowPrivateIPAddresses are independent gates: opting a middleware into +// loopback hosts must not also open up RFC 1918 private ranges. +func TestIssue154PrivateIPIndependence(t *testing.T) { + middleware := createMinimalMiddleware() + middleware.allowLoopbackHosts = true + middleware.allowPrivateIPAddresses = false + + privateHosts := []string{"10.0.0.5", "192.168.1.10", "172.16.0.1"} + for _, host := range privateHosts { + t.Run(host, func(t *testing.T) { + err := middleware.validateHost(host) + if err == nil { + t.Fatalf("validateHost(%q) must be rejected when allowPrivateIPAddresses=false", host) + } + if !strings.Contains(err.Error(), "private/internal") { + t.Fatalf("validateHost(%q) error = %q, want substring %q", host, err.Error(), "private/internal") + } + }) + } +} + +// TestIssue154IsLoopbackProviderURL exercises the construction-time +// derivation helper in isolation, including malformed input that must not +// panic. +func TestIssue154IsLoopbackProviderURL(t *testing.T) { + cases := []struct { + name string + providerURL string + want bool + }{ + {"http localhost with port", "http://localhost:8080", true}, + {"http loopback IPv4 with port", "http://127.0.0.1:9000", true}, + {"http loopback IPv6 with port", "http://[::1]:9000", true}, + {"https localhost uppercase", "https://LOCALHOST:9000", true}, + {"https real remote provider", "https://accounts.google.com", false}, + {"https another remote provider", "https://idp.example.com", false}, + {"empty string", "", false}, + {"malformed URL must not panic", "://garbage", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := isLoopbackProviderURL(tc.providerURL) + if got != tc.want { + t.Fatalf("isLoopbackProviderURL(%q) = %v, want %v", tc.providerURL, got, tc.want) + } + }) + } +} + +// TestIssue154BuildAuthURLLoopbackProvider is the issue scenario reproduced +// at the unit level: a loopback authURL must produce a real redirect URL +// when allowLoopbackHosts is set, and the pre-fix behavior (empty string, +// the infinite-redirect-loop trigger) must be pinned as the default when it +// is not. +func TestIssue154BuildAuthURLLoopbackProvider(t *testing.T) { + t.Run("allowLoopbackHosts=true builds a real auth URL", func(t *testing.T) { + middleware := createMinimalMiddleware() + middleware.allowLoopbackHosts = true + middleware.authURL = "http://localhost:8080/oauth/authorize" + middleware.issuerURL = "http://localhost:8080" + + got := middleware.buildAuthURL("http://localhost:5555/callback", "state123", "nonce456", "") + + if got == "" { + t.Fatal("buildAuthURL returned empty string for a loopback provider with allowLoopbackHosts=true") + } + if !strings.HasPrefix(got, "http://localhost:8080/oauth/authorize?") { + t.Fatalf("buildAuthURL = %q, want prefix %q", got, "http://localhost:8080/oauth/authorize?") + } + for _, want := range []string{"client_id=", "state=state123", "nonce=nonce456"} { + if !strings.Contains(got, want) { + t.Fatalf("buildAuthURL = %q, want it to contain %q", got, want) + } + } + }) + + t.Run("default allowLoopbackHosts=false pins pre-fix empty-string behavior", func(t *testing.T) { + middleware := createMinimalMiddleware() // allowLoopbackHosts left at zero value (false) + middleware.authURL = "http://localhost:8080/oauth/authorize" + middleware.issuerURL = "http://localhost:8080" + + got := middleware.buildAuthURL("http://localhost:5555/callback", "state123", "nonce456", "") + + if got != "" { + t.Fatalf("buildAuthURL = %q, want empty string when a loopback provider is not opted in", got) + } + }) +} + +// issue154BaseConfig returns a complete, valid Config with the given +// providerURL, following the minimal-valid-config pattern used elsewhere in +// this package (e.g. TestRank2And6_InvalidConfigFailsClosed in +// security_audit_fixes_test.go). +func issue154BaseConfig(providerURL string) *Config { + return &Config{ + ProviderURL: providerURL, + ClientID: "test-client", + ClientSecret: "test-secret", + CallbackURL: "/callback", + SessionEncryptionKey: "this-is-a-valid-session-key-32b!", + RateLimit: 100, + } +} + +// TestIssue154NewWithContextDerivation proves NewWithContext derives +// allowLoopbackHosts from config.ProviderURL at construction time (main.go, +// the `allowLoopbackHosts: isLoopbackProviderURL(config.ProviderURL)` field +// initializer). The field is set synchronously before the metadata-discovery +// goroutine starts, so this needs no live discovery endpoint: the ports used +// below (a loopback port nothing listens on, and an RFC 2606 .invalid host) +// only need to fail fast once the background goroutine attempts discovery — +// Close() cancels that goroutine's context regardless of outcome. +func TestIssue154NewWithContextDerivation(t *testing.T) { + t.Run("loopback providerURL derives allowLoopbackHosts=true", func(t *testing.T) { + // A real httptest server (loopback by construction) so the + // background discovery goroutine NewWithContext starts completes + // quickly and Close() below does not have to wait out its 10s + // goroutine-shutdown timeout for an unreachable host. Mirrors + // TestInitializeMetadata's server setup in main_initialization_test.go. + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/.well-known/openid-configuration") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(ProviderMetadata{ + Issuer: srv.URL, + AuthURL: srv.URL + "/auth", + TokenURL: srv.URL + "/token", + JWKSURL: srv.URL + "/jwks", + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + cfg := issue154BaseConfig(srv.URL) + oidc, err := NewWithContext(context.Background(), cfg, nil, "issue154-loopback") + if err != nil { + t.Fatalf("NewWithContext must accept a valid loopback config, got: %v", err) + } + defer func() { _ = oidc.Close() }() + + if !oidc.allowLoopbackHosts { + t.Fatal("expected allowLoopbackHosts=true for a loopback providerURL") + } + }) + + t.Run("non-loopback providerURL derives allowLoopbackHosts=false", func(t *testing.T) { + cfg := issue154BaseConfig("https://provider.invalid") + oidc, err := NewWithContext(context.Background(), cfg, nil, "issue154-remote") + if err != nil { + t.Fatalf("NewWithContext must accept a valid remote config, got: %v", err) + } + defer func() { _ = oidc.Close() }() + + if oidc.allowLoopbackHosts { + t.Fatal("expected allowLoopbackHosts=false for a non-loopback providerURL") + } + }) +} + +// TestIssue154DiscoveryAndURLValidationAgree catches layer drift between the +// discovery-endpoint sanitizer (updateMetadataEndpoints, which recomputes +// isLoopbackProviderURL(t.providerURL) itself per its doc comment, since +// test helpers build &TraefikOidc{} literals without going through +// NewWithContext) and the outbound URL validator (validateHost, gated on the +// allowLoopbackHosts field). Both layers must agree that a loopback provider +// is usable end-to-end: discovery must accept the loopback authURL, and +// buildAuthURL must build a real URL once allowLoopbackHosts is set to +// mirror what NewWithContext would have derived. +func TestIssue154DiscoveryAndURLValidationAgree(t *testing.T) { + oidc := &TraefikOidc{ + providerURL: "http://localhost:8080", + logger: NewLogger("debug"), + clientID: "test-client", + } + + metadata := &ProviderMetadata{ + Issuer: "http://localhost:8080", + AuthURL: "http://localhost:8080/oauth/authorize", + TokenURL: "http://localhost:8080/oauth/token", + JWKSURL: "http://localhost:8080/jwks", + } + oidc.updateMetadataEndpoints(metadata) + + if oidc.authURL == "" { + t.Fatal("updateMetadataEndpoints rejected a loopback authURL for a loopback providerURL (discovery layer drift)") + } + + // The URL-validation layer must independently agree once + // allowLoopbackHosts mirrors what NewWithContext would have derived from + // this same loopback providerURL. + oidc.allowLoopbackHosts = true + if got := oidc.buildAuthURL("http://localhost:5555/callback", "state", "nonce", ""); got == "" { + t.Fatal("buildAuthURL returned empty for the discovered loopback authURL with allowLoopbackHosts=true (validation layer drift)") + } +} diff --git a/main.go b/main.go index 2d3409d..55808be 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,6 @@ import ( "encoding/hex" "fmt" "net/http" - "net/url" "os" "runtime" "strings" @@ -314,6 +313,7 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name scopeFilter: NewScopeFilter(logger), // NEW - for discovery-based scope filtering dcrConfig: config.DynamicClientRegistration, allowPrivateIPAddresses: config.AllowPrivateIPAddresses, + allowLoopbackHosts: isLoopbackProviderURL(config.ProviderURL), minimalHeaders: config.MinimalHeaders, stripAuthCookies: config.StripAuthCookies, enableBackchannelLogout: config.EnableBackchannelLogout, @@ -562,10 +562,11 @@ func (t *TraefikOidc) updateMetadataEndpoints(metadata *ProviderMetadata) { // discovered endpoint pointed at a blocked address before the plugin issues // outbound requests to it, so it can never be used to reach the cloud // metadata service or an internal host. - allowLoopback := false - if pu, err := url.Parse(t.providerURL); err == nil { - allowLoopback = isLoopbackHost(pu.Hostname()) - } + // Recomputed from t.providerURL (not the allowLoopbackHosts field) so this + // stays correct for TraefikOidc values built outside New() (test helpers + // construct &TraefikOidc{providerURL: ...} directly, leaving + // allowLoopbackHosts at its zero value). + allowLoopback := isLoopbackProviderURL(t.providerURL) sanitize := func(name, raw string) string { if err := t.validateDiscoveredEndpoint(raw, allowLoopback); err != nil { t.logger.Errorf("Ignoring discovered %s endpoint %q: %v", name, raw, err) diff --git a/settings.go b/settings.go index 48ce5d5..d9476ce 100644 --- a/settings.go +++ b/settings.go @@ -671,6 +671,18 @@ func isLoopbackHost(host string) bool { return false } +// isLoopbackProviderURL reports whether providerURL's host is loopback +// (localhost or a loopback IP literal). Used at construction time to derive +// allowLoopbackHosts, so local-development providers are not blocked by +// validateHost's outbound URL checks. +func isLoopbackProviderURL(providerURL string) bool { + pu, err := url.Parse(providerURL) + if err != nil { + return false + } + return isLoopbackHost(pu.Hostname()) +} + // isValidLogLevel checks if the provided log level string is one of the supported values ("debug", "info", "error"). // // Parameters: diff --git a/types.go b/types.go index 96aca8c..48225c6 100644 --- a/types.go +++ b/types.go @@ -91,100 +91,105 @@ type TraefikOidc struct { // Hot-path readers (middleware.ServeHTTP, token verification) load it // directly; less-frequent paths still acquire metadataMu.RLock and // read the individual fields below. - metadataSnapshot atomic.Value + metadataSnapshot atomic.Value // lastMetadataRetryNano is the UnixNano timestamp of the last metadata // recovery attempt. Stored atomically so the hot ServeHTTP path can // throttle retries without acquiring metadataRetryMutex on every request. - lastMetadataRetryNano int64 + lastMetadataRetryNano int64 // firstRequestStarted is 0 until the very first non-health request fires // the background-task bootstrap; then it flips to 1 via CAS. Replaces the // firstRequestMutex + firstRequestReceived combo which previously took // a write lock on every non-health request forever. - firstRequestStarted int32 + firstRequestStarted int32 // metadataRefreshStartedAtomic is the CAS-only variant of the old // metadataRefreshStarted bool. Both flags live under the same atomic so // concurrent first-request goroutines race exactly once. metadataRefreshStartedAtomic int32 - jwkCache JWKCacheInterface - jwtVerifier JWTVerifier - ctx context.Context - tokenVerifier TokenVerifier - next http.Handler - tokenExchanger TokenExchanger - tokenBlacklist CacheInterface - tokenTypeCache CacheInterface - introspectionCache CacheInterface - initComplete chan struct{} - limiter *rate.Limiter - headerTemplates map[string]*template.Template - sessionManager *SessionManager - tokenCleanupStopChan chan struct{} - excludedURLs map[string]struct{} - extractClaimsFunc func(tokenString string) (map[string]any, error) - initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) - metadataCache *MetadataCache - allowedRolesAndGroups map[string]struct{} - allowedUsers map[string]struct{} - allowedUserDomains map[string]struct{} - tokenCache *TokenCache - httpClient *http.Client - tokenHTTPClient *http.Client - logger *Logger - metadataRefreshStopChan chan struct{} - cancelFunc context.CancelFunc - errorRecoveryManager *ErrorRecoveryManager - tokenResilienceManager *TokenResilienceManager - refreshCoordinator *RefreshCoordinator - goroutineWG *sync.WaitGroup - dcrConfig *DynamicClientRegistrationConfig - dynamicClientRegistrar *DynamicClientRegistrar - scopeFilter *ScopeFilter - securityHeadersApplier func(http.ResponseWriter, *http.Request) - userIdentifierClaim string - revocationURL string - name string - redirURLPath string - logoutURLPath string - tokenURL string - authURL string - endSessionURL string - postLogoutRedirectURI string - jwksURL string - issuerURL string - groupClaimName string - introspectionURL string - providerURL string - roleClaimName string - audience string - clientID string - clientSecret string - clientAuthMethod string - clientAssertion *ClientAssertionSigner - registrationURL string - backchannelLogoutPath string - frontchannelLogoutPath string - scopesSupported []string - scopes []string - extraAuthParams map[string]string - refreshGracePeriod time.Duration - maxRefreshTokenAge time.Duration - metadataMu sync.RWMutex - shutdownOnce sync.Once - sessionInvalidationCache CacheInterface - refreshResultCache CacheInterface - minimalHeaders bool - stripAuthCookies bool - enableBackchannelLogout bool - enableFrontchannelLogout bool - requireTokenIntrospection bool - allowPrivateIPAddresses bool - disableReplayDetection bool - allowOpaqueTokens bool - strictAudienceValidation bool - overrideScopes bool - enablePKCE bool - forceHTTPS bool - suppressDiagnosticLogs bool + jwkCache JWKCacheInterface + jwtVerifier JWTVerifier + ctx context.Context + tokenVerifier TokenVerifier + next http.Handler + tokenExchanger TokenExchanger + tokenBlacklist CacheInterface + tokenTypeCache CacheInterface + introspectionCache CacheInterface + initComplete chan struct{} + limiter *rate.Limiter + headerTemplates map[string]*template.Template + sessionManager *SessionManager + tokenCleanupStopChan chan struct{} + excludedURLs map[string]struct{} + extractClaimsFunc func(tokenString string) (map[string]any, error) + initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) + metadataCache *MetadataCache + allowedRolesAndGroups map[string]struct{} + allowedUsers map[string]struct{} + allowedUserDomains map[string]struct{} + tokenCache *TokenCache + httpClient *http.Client + tokenHTTPClient *http.Client + logger *Logger + metadataRefreshStopChan chan struct{} + cancelFunc context.CancelFunc + errorRecoveryManager *ErrorRecoveryManager + tokenResilienceManager *TokenResilienceManager + refreshCoordinator *RefreshCoordinator + goroutineWG *sync.WaitGroup + dcrConfig *DynamicClientRegistrationConfig + dynamicClientRegistrar *DynamicClientRegistrar + scopeFilter *ScopeFilter + securityHeadersApplier func(http.ResponseWriter, *http.Request) + userIdentifierClaim string + revocationURL string + name string + redirURLPath string + logoutURLPath string + tokenURL string + authURL string + endSessionURL string + postLogoutRedirectURI string + jwksURL string + issuerURL string + groupClaimName string + introspectionURL string + providerURL string + roleClaimName string + audience string + clientID string + clientSecret string + clientAuthMethod string + clientAssertion *ClientAssertionSigner + registrationURL string + backchannelLogoutPath string + frontchannelLogoutPath string + scopesSupported []string + scopes []string + extraAuthParams map[string]string + refreshGracePeriod time.Duration + maxRefreshTokenAge time.Duration + metadataMu sync.RWMutex + shutdownOnce sync.Once + sessionInvalidationCache CacheInterface + refreshResultCache CacheInterface + minimalHeaders bool + stripAuthCookies bool + enableBackchannelLogout bool + enableFrontchannelLogout bool + requireTokenIntrospection bool + allowPrivateIPAddresses bool + // allowLoopbackHosts permits loopback/localhost hosts in outbound URL + // validation (validateHost). Derived at construction time from a loopback + // providerURL (local development), never operator-set directly. Mirrors + // the allowLoopback derivation in updateMetadataEndpoints. + allowLoopbackHosts bool + disableReplayDetection bool + allowOpaqueTokens bool + strictAudienceValidation bool + overrideScopes bool + enablePKCE bool + forceHTTPS bool + suppressDiagnosticLogs bool // Bearer-auth runtime state (populated only when EnableBearerAuth=true). bearerIdentifierClaim string diff --git a/url_helpers.go b/url_helpers.go index c0a6511..3d3f649 100644 --- a/url_helpers.go +++ b/url_helpers.go @@ -390,8 +390,15 @@ func (t *TraefikOidc) validateHost(host string) error { ip := net.ParseIP(hostname) if ip != nil { - // Always block loopback, link-local, and multicast addresses - if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + // Loopback addresses are blocked by default, but allowed when the + // plugin was constructed with a loopback providerURL (local + // development) — see allowLoopbackHosts. + if ip.IsLoopback() && !t.allowLoopbackHosts { + return fmt.Errorf("access to loopback/link-local IP addresses is not allowed: %s", ip.String()) + } + + // Link-local and multicast addresses are always blocked. + if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { return fmt.Errorf("access to loopback/link-local IP addresses is not allowed: %s", ip.String()) } @@ -405,16 +412,22 @@ func (t *TraefikOidc) validateHost(host string) error { } } - dangerousHosts := map[string]bool{ - "localhost": true, - "127.0.0.1": true, - "::1": true, + // Always blocked regardless of allowLoopbackHosts: these have no IP-branch + // backstop (net.ParseIP returns nil for hostnames), so bypassing them + // would open an SSRF path to the cloud metadata service. + alwaysDangerousHosts := map[string]bool{ "0.0.0.0": true, "169.254.169.254": true, "metadata.google.internal": true, } - if dangerousHosts[strings.ToLower(hostname)] { + if alwaysDangerousHosts[strings.ToLower(hostname)] { + return fmt.Errorf("access to dangerous hostname is not allowed: %s", hostname) + } + + // localhost/loopback literals are blocked unless allowLoopbackHosts + // permits them (see allowLoopbackHosts doc comment on TraefikOidc). + if !t.allowLoopbackHosts && isLoopbackHost(hostname) { return fmt.Errorf("access to dangerous hostname is not allowed: %s", hostname) }