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.
This commit is contained in:
2026-08-09 00:47:25 +01:00
parent edb7174cc8
commit 78b6f193b3
5 changed files with 452 additions and 95 deletions
+326
View File
@@ -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)")
}
}
+6 -5
View File
@@ -9,7 +9,6 @@ import (
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"os" "os"
"runtime" "runtime"
"strings" "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 scopeFilter: NewScopeFilter(logger), // NEW - for discovery-based scope filtering
dcrConfig: config.DynamicClientRegistration, dcrConfig: config.DynamicClientRegistration,
allowPrivateIPAddresses: config.AllowPrivateIPAddresses, allowPrivateIPAddresses: config.AllowPrivateIPAddresses,
allowLoopbackHosts: isLoopbackProviderURL(config.ProviderURL),
minimalHeaders: config.MinimalHeaders, minimalHeaders: config.MinimalHeaders,
stripAuthCookies: config.StripAuthCookies, stripAuthCookies: config.StripAuthCookies,
enableBackchannelLogout: config.EnableBackchannelLogout, enableBackchannelLogout: config.EnableBackchannelLogout,
@@ -562,10 +562,11 @@ func (t *TraefikOidc) updateMetadataEndpoints(metadata *ProviderMetadata) {
// discovered endpoint pointed at a blocked address before the plugin issues // 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 // outbound requests to it, so it can never be used to reach the cloud
// metadata service or an internal host. // metadata service or an internal host.
allowLoopback := false // Recomputed from t.providerURL (not the allowLoopbackHosts field) so this
if pu, err := url.Parse(t.providerURL); err == nil { // stays correct for TraefikOidc values built outside New() (test helpers
allowLoopback = isLoopbackHost(pu.Hostname()) // construct &TraefikOidc{providerURL: ...} directly, leaving
} // allowLoopbackHosts at its zero value).
allowLoopback := isLoopbackProviderURL(t.providerURL)
sanitize := func(name, raw string) string { sanitize := func(name, raw string) string {
if err := t.validateDiscoveredEndpoint(raw, allowLoopback); err != nil { if err := t.validateDiscoveredEndpoint(raw, allowLoopback); err != nil {
t.logger.Errorf("Ignoring discovered %s endpoint %q: %v", name, raw, err) t.logger.Errorf("Ignoring discovered %s endpoint %q: %v", name, raw, err)
+12
View File
@@ -671,6 +671,18 @@ func isLoopbackHost(host string) bool {
return false 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"). // isValidLogLevel checks if the provided log level string is one of the supported values ("debug", "info", "error").
// //
// Parameters: // Parameters:
+88 -83
View File
@@ -91,100 +91,105 @@ type TraefikOidc struct {
// Hot-path readers (middleware.ServeHTTP, token verification) load it // Hot-path readers (middleware.ServeHTTP, token verification) load it
// directly; less-frequent paths still acquire metadataMu.RLock and // directly; less-frequent paths still acquire metadataMu.RLock and
// read the individual fields below. // read the individual fields below.
metadataSnapshot atomic.Value metadataSnapshot atomic.Value
// lastMetadataRetryNano is the UnixNano timestamp of the last metadata // lastMetadataRetryNano is the UnixNano timestamp of the last metadata
// recovery attempt. Stored atomically so the hot ServeHTTP path can // recovery attempt. Stored atomically so the hot ServeHTTP path can
// throttle retries without acquiring metadataRetryMutex on every request. // throttle retries without acquiring metadataRetryMutex on every request.
lastMetadataRetryNano int64 lastMetadataRetryNano int64
// firstRequestStarted is 0 until the very first non-health request fires // firstRequestStarted is 0 until the very first non-health request fires
// the background-task bootstrap; then it flips to 1 via CAS. Replaces the // the background-task bootstrap; then it flips to 1 via CAS. Replaces the
// firstRequestMutex + firstRequestReceived combo which previously took // firstRequestMutex + firstRequestReceived combo which previously took
// a write lock on every non-health request forever. // a write lock on every non-health request forever.
firstRequestStarted int32 firstRequestStarted int32
// metadataRefreshStartedAtomic is the CAS-only variant of the old // metadataRefreshStartedAtomic is the CAS-only variant of the old
// metadataRefreshStarted bool. Both flags live under the same atomic so // metadataRefreshStarted bool. Both flags live under the same atomic so
// concurrent first-request goroutines race exactly once. // concurrent first-request goroutines race exactly once.
metadataRefreshStartedAtomic int32 metadataRefreshStartedAtomic int32
jwkCache JWKCacheInterface jwkCache JWKCacheInterface
jwtVerifier JWTVerifier jwtVerifier JWTVerifier
ctx context.Context ctx context.Context
tokenVerifier TokenVerifier tokenVerifier TokenVerifier
next http.Handler next http.Handler
tokenExchanger TokenExchanger tokenExchanger TokenExchanger
tokenBlacklist CacheInterface tokenBlacklist CacheInterface
tokenTypeCache CacheInterface tokenTypeCache CacheInterface
introspectionCache CacheInterface introspectionCache CacheInterface
initComplete chan struct{} initComplete chan struct{}
limiter *rate.Limiter limiter *rate.Limiter
headerTemplates map[string]*template.Template headerTemplates map[string]*template.Template
sessionManager *SessionManager sessionManager *SessionManager
tokenCleanupStopChan chan struct{} tokenCleanupStopChan chan struct{}
excludedURLs map[string]struct{} excludedURLs map[string]struct{}
extractClaimsFunc func(tokenString string) (map[string]any, error) extractClaimsFunc func(tokenString string) (map[string]any, error)
initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string)
metadataCache *MetadataCache metadataCache *MetadataCache
allowedRolesAndGroups map[string]struct{} allowedRolesAndGroups map[string]struct{}
allowedUsers map[string]struct{} allowedUsers map[string]struct{}
allowedUserDomains map[string]struct{} allowedUserDomains map[string]struct{}
tokenCache *TokenCache tokenCache *TokenCache
httpClient *http.Client httpClient *http.Client
tokenHTTPClient *http.Client tokenHTTPClient *http.Client
logger *Logger logger *Logger
metadataRefreshStopChan chan struct{} metadataRefreshStopChan chan struct{}
cancelFunc context.CancelFunc cancelFunc context.CancelFunc
errorRecoveryManager *ErrorRecoveryManager errorRecoveryManager *ErrorRecoveryManager
tokenResilienceManager *TokenResilienceManager tokenResilienceManager *TokenResilienceManager
refreshCoordinator *RefreshCoordinator refreshCoordinator *RefreshCoordinator
goroutineWG *sync.WaitGroup goroutineWG *sync.WaitGroup
dcrConfig *DynamicClientRegistrationConfig dcrConfig *DynamicClientRegistrationConfig
dynamicClientRegistrar *DynamicClientRegistrar dynamicClientRegistrar *DynamicClientRegistrar
scopeFilter *ScopeFilter scopeFilter *ScopeFilter
securityHeadersApplier func(http.ResponseWriter, *http.Request) securityHeadersApplier func(http.ResponseWriter, *http.Request)
userIdentifierClaim string userIdentifierClaim string
revocationURL string revocationURL string
name string name string
redirURLPath string redirURLPath string
logoutURLPath string logoutURLPath string
tokenURL string tokenURL string
authURL string authURL string
endSessionURL string endSessionURL string
postLogoutRedirectURI string postLogoutRedirectURI string
jwksURL string jwksURL string
issuerURL string issuerURL string
groupClaimName string groupClaimName string
introspectionURL string introspectionURL string
providerURL string providerURL string
roleClaimName string roleClaimName string
audience string audience string
clientID string clientID string
clientSecret string clientSecret string
clientAuthMethod string clientAuthMethod string
clientAssertion *ClientAssertionSigner clientAssertion *ClientAssertionSigner
registrationURL string registrationURL string
backchannelLogoutPath string backchannelLogoutPath string
frontchannelLogoutPath string frontchannelLogoutPath string
scopesSupported []string scopesSupported []string
scopes []string scopes []string
extraAuthParams map[string]string extraAuthParams map[string]string
refreshGracePeriod time.Duration refreshGracePeriod time.Duration
maxRefreshTokenAge time.Duration maxRefreshTokenAge time.Duration
metadataMu sync.RWMutex metadataMu sync.RWMutex
shutdownOnce sync.Once shutdownOnce sync.Once
sessionInvalidationCache CacheInterface sessionInvalidationCache CacheInterface
refreshResultCache CacheInterface refreshResultCache CacheInterface
minimalHeaders bool minimalHeaders bool
stripAuthCookies bool stripAuthCookies bool
enableBackchannelLogout bool enableBackchannelLogout bool
enableFrontchannelLogout bool enableFrontchannelLogout bool
requireTokenIntrospection bool requireTokenIntrospection bool
allowPrivateIPAddresses bool allowPrivateIPAddresses bool
disableReplayDetection bool // allowLoopbackHosts permits loopback/localhost hosts in outbound URL
allowOpaqueTokens bool // validation (validateHost). Derived at construction time from a loopback
strictAudienceValidation bool // providerURL (local development), never operator-set directly. Mirrors
overrideScopes bool // the allowLoopback derivation in updateMetadataEndpoints.
enablePKCE bool allowLoopbackHosts bool
forceHTTPS bool disableReplayDetection bool
suppressDiagnosticLogs bool allowOpaqueTokens bool
strictAudienceValidation bool
overrideScopes bool
enablePKCE bool
forceHTTPS bool
suppressDiagnosticLogs bool
// Bearer-auth runtime state (populated only when EnableBearerAuth=true). // Bearer-auth runtime state (populated only when EnableBearerAuth=true).
bearerIdentifierClaim string bearerIdentifierClaim string
+20 -7
View File
@@ -390,8 +390,15 @@ func (t *TraefikOidc) validateHost(host string) error {
ip := net.ParseIP(hostname) ip := net.ParseIP(hostname)
if ip != nil { if ip != nil {
// Always block loopback, link-local, and multicast addresses // Loopback addresses are blocked by default, but allowed when the
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { // 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()) 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{ // Always blocked regardless of allowLoopbackHosts: these have no IP-branch
"localhost": true, // backstop (net.ParseIP returns nil for hostnames), so bypassing them
"127.0.0.1": true, // would open an SSRF path to the cloud metadata service.
"::1": true, alwaysDangerousHosts := map[string]bool{
"0.0.0.0": true, "0.0.0.0": true,
"169.254.169.254": true, "169.254.169.254": true,
"metadata.google.internal": 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) return fmt.Errorf("access to dangerous hostname is not allowed: %s", hostname)
} }