// Package traefikoidc provides OIDC authentication middleware for Traefik. // This file contains URL-related helper methods for building, validating, and processing URLs // used in the OIDC authentication flow. package traefikoidc import ( "errors" "fmt" "net" "net/url" "path" "strings" ) // ErrDiscoveredEndpointSchemeDowngrade is returned (wrapped) by // validateDiscoveredEndpoint when a provider served over https advertises a // plaintext http endpoint (R146). A named sentinel lets updateMetadataEndpoints // detect this specific rejection with errors.Is and escalate it beyond the // generic per-endpoint log line (FIX-26; see CHANGELOG.md). var ErrDiscoveredEndpointSchemeDowngrade = errors.New("discovered endpoint uses plaintext http while the provider is https; refusing scheme downgrade") // determineExcludedURL checks if a URL path should bypass OIDC authentication. // It compares the request path against configured excluded URL prefixes. // Parameters: // - currentRequest: The request path to check. // // Returns: // - true if the URL should be excluded from authentication, false otherwise. func (t *TraefikOidc) determineExcludedURL(currentRequest string) bool { for excludedURL := range t.excludedURLs { if pathExcluded(currentRequest, excludedURL) { t.logger.Debugf("URL is excluded - got %s / excluded hit: %s", currentRequest, excludedURL) return true } } return false } // pathExcluded reports whether requestPath is covered by an excluded prefix at a // natural boundary: an exact match, a sub-path ("/public" → "/public/x"), or a // file extension ("/favicon" → "/favicon.ico"). It deliberately does NOT match // an unrelated sibling such as "/publicsecret", so a configured exclusion can no // longer be widened into an authentication bypass on a different resource. func pathExcluded(requestPath, excluded string) bool { // Normalize dot segments and redundant slashes so an exclusion cannot be // escaped by traversal. Go's server leaves ".." in req.URL.Path (verified: // url.ParseRequestURI keeps dot segments), so "/public/../admin" would match // an "/public" exclusion even though the real resource is "/admin". path.Clean // resolves this to "/admin" before the boundary check. "." (empty or bare) // maps back to "" preserving the original root-match semantics. requestPath = path.Clean(requestPath) if requestPath == "." { requestPath = "" } excluded = strings.TrimRight(excluded, "/") if excluded == "" { // A "/" (root) exclusion only matches the root path, not everything. return requestPath == "" || requestPath == "/" } if requestPath == excluded { return true } if !strings.HasPrefix(requestPath, excluded) { return false } switch requestPath[len(excluded)] { case '/', '.': return true default: return false } } // buildAuthURL constructs the OIDC provider authorization URL. // It builds the URL with all necessary parameters including client_id, scopes, // PKCE parameters, and provider-specific parameters for Google and Azure. // Parameters: // - redirectURL: The callback URL for after authentication. // - state: The CSRF token for state validation. // - nonce: The nonce for replay protection. // - codeChallenge: The PKCE code challenge (if PKCE is enabled). // // Returns: // - The fully constructed authorization URL string. func (t *TraefikOidc) buildAuthURL(redirectURL, state, nonce, codeChallenge string) string { // Snapshot clientID under metadataMu: DCR rewrites it at runtime (R137). clientID, _, _, _, _ := t.clientCredentials() params := url.Values{} params.Set("client_id", clientID) params.Set("response_type", "code") params.Set("redirect_uri", redirectURL) params.Set("state", state) params.Set("nonce", nonce) // Non-standard `audience` param for custom API audiences (e.g. Auth0). // Keyed on explicitAudience, not audience — see TraefikOidc.explicitAudience. // Only add if configured and different from client_id; ID tokens always // have aud=client_id per OIDC spec. clientID comes from the metadataMu // snapshot above, because DCR rewrites it at runtime (R137). if t.explicitAudience != "" && t.explicitAudience != clientID { params.Set("audience", t.explicitAudience) t.logger.Debugf("Adding audience parameter to authorize URL: %s", t.explicitAudience) } // RFC 8707 resource indicator — see Config.Resource. Runs before // extraAuthParams so an operator-configured "resource" key there cannot // override or duplicate it. if t.resource != "" { params.Set("resource", t.resource) t.logger.Debugf("Adding resource parameter to authorize URL: %s", t.resource) } if t.enablePKCE && codeChallenge != "" { params.Set("code_challenge", codeChallenge) params.Set("code_challenge_method", "S256") } scopes := make([]string, len(t.scopes)) copy(scopes, t.scopes) // Apply discovery-based scope filtering if available // Read scopesSupported with RLock t.metadataMu.RLock() scopesSupported := t.scopesSupported t.metadataMu.RUnlock() if t.scopeFilter != nil && len(scopesSupported) > 0 { scopes = t.scopeFilter.FilterSupportedScopes(scopes, scopesSupported, t.providerURL) t.logger.Debugf("TraefikOidc.buildAuthURL: After discovery filtering: %v", scopes) } // Then apply provider-specific modifications if t.isGoogleProvider() { // Google: Remove offline_access if present, add access_type=offline filteredScopes := make([]string, 0, len(scopes)) for _, scope := range scopes { if scope != "offline_access" { filteredScopes = append(filteredScopes, scope) } } scopes = filteredScopes params.Set("access_type", "offline") t.logger.Debug("Google OIDC provider detected, added access_type=offline for refresh tokens") params.Set("prompt", "consent") t.logger.Debug("Google OIDC provider detected, added prompt=consent to ensure refresh tokens") } else if t.isAzureProvider() { params.Set("response_mode", "query") t.logger.Debug("Azure AD provider detected, added response_mode=query") hasOfflineAccess := false for _, scope := range scopes { if scope == "offline_access" { hasOfflineAccess = true break } } if !t.overrideScopes || (t.overrideScopes && len(t.scopes) == 0) { if !hasOfflineAccess { scopes = append(scopes, "offline_access") t.logger.Debugf("Azure AD provider: Added offline_access scope (overrideScopes: %t, user scopes count: %d)", t.overrideScopes, len(t.scopes)) } } else { t.logger.Debugf("Azure AD provider: User is overriding scopes (count: %d), offline_access not automatically added.", len(t.scopes)) } } else { if !t.overrideScopes || (t.overrideScopes && len(t.scopes) == 0) { hasOfflineAccess := false for _, scope := range scopes { if scope == "offline_access" { hasOfflineAccess = true break } } if !hasOfflineAccess { scopes = append(scopes, "offline_access") t.logger.Debugf("Standard provider: Added offline_access scope (overrideScopes: %t, user scopes count: %d)", t.overrideScopes, len(t.scopes)) } } else { t.logger.Debugf("Standard provider: User is overriding scopes (count: %d), offline_access not automatically added.", len(t.scopes)) } } // Final filtering pass to remove anything the provider doesn't support // Read scopesSupported with RLock t.metadataMu.RLock() scopesSupported = t.scopesSupported t.metadataMu.RUnlock() if t.scopeFilter != nil && len(scopesSupported) > 0 { scopes = t.scopeFilter.FilterSupportedScopes(scopes, scopesSupported, t.providerURL) t.logger.Debugf("TraefikOidc.buildAuthURL: After final filtering: %v", scopes) // OIDC Core §3.1.2.1 requires the 'openid' scope for an authorization // (ID-token) request, even when scopes_supported is optional and happens // to omit it. FilterSupportedScopes only restores openid when EVERY // requested scope is dropped, so openid can be absent here if the // provider omits openid from scopes_supported while leaving other // configured scopes supported. Guarantee its presence. (When the // filter is inactive — no discovery scopes_supported — an explicit // operator scope override, e.g. omitting openid, is respected as-is.) scopes = t.scopeFilter.EnsureOpenIDScope(scopes) } if len(scopes) > 0 { finalScopeString := strings.Join(scopes, " ") params.Set("scope", finalScopeString) t.logger.Debugf("TraefikOidc.buildAuthURL: Final scope string being sent to OIDC provider: %s", finalScopeString) } // Apply operator-configured extra authorization parameters (e.g. // screen_hint, login_hint, ui_locales, prompt). These are added last but // can never override parameters the plugin itself manages (client_id, // state, nonce, redirect_uri, code_challenge, scope, response_type, ...): // a key already present in params is left untouched, so this cannot // weaken security-critical parameters. for key, value := range t.extraAuthParams { if params.Get(key) == "" { params.Set(key, value) t.logger.Debugf("TraefikOidc.buildAuthURL: Added extra auth param %s", key) } else { t.logger.Debugf("TraefikOidc.buildAuthURL: Skipped extra auth param %s (already set by plugin)", key) } } // Read authURL with RLock t.metadataMu.RLock() authURL := t.authURL t.metadataMu.RUnlock() return t.buildURLWithParams(authURL, params) } // buildURLWithParams constructs a URL by combining a base URL with query parameters. // It handles both relative and absolute URLs, validates URL security, // and properly encodes query parameters. // Parameters: // - baseURL: The base URL to append parameters to. // - params: The query parameters to append. // // Returns: // - The fully constructed URL string with appended query parameters. // // preserveBaseQuery seeds url parameters from the base URL's pre-existing // query string, without overriding authoritative plugin-supplied params. // Some IdPs embed client_id or template params in their discovered // authorization/end-session endpoints; overwriting RawQuery dropped them // (R101). func (t *TraefikOidc) preserveBaseQuery(u *url.URL, params url.Values) { for k, vs := range u.Query() { if params.Get(k) == "" { for _, v := range vs { params.Add(k, v) } } } } func (t *TraefikOidc) buildURLWithParams(baseURL string, params url.Values) string { if baseURL != "" { if strings.HasPrefix(baseURL, "http://") || strings.HasPrefix(baseURL, "https://") { if err := t.validateURL(baseURL); err != nil { t.logger.Errorf("URL validation failed for %s: %v", baseURL, err) return "" } } } if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { // Read issuerURL with RLock t.metadataMu.RLock() issuerURL := t.issuerURL t.metadataMu.RUnlock() issuerURLParsed, err := url.Parse(issuerURL) if err != nil { t.logger.Errorf("Could not parse issuerURL: %s. Error: %v", issuerURL, err) return "" } baseURLParsed, err := url.Parse(baseURL) if err != nil { t.logger.Errorf("Could not parse baseURL: %s. Error: %v", baseURL, err) return "" } resolvedURL := issuerURLParsed.ResolveReference(baseURLParsed) if err := t.validateURL(resolvedURL.String()); err != nil { t.logger.Errorf("Resolved URL validation failed for %s: %v", resolvedURL.String(), err) return "" } t.preserveBaseQuery(resolvedURL, params) resolvedURL.RawQuery = params.Encode() return resolvedURL.String() } u, err := url.Parse(baseURL) if err != nil { t.logger.Errorf("Could not parse absolute baseURL: %s. Error: %v", baseURL, err) return "" } if err := t.validateParsedURL(u); err != nil { t.logger.Errorf("Parsed URL validation failed for %s: %v", baseURL, err) return "" } t.preserveBaseQuery(u, params) u.RawQuery = params.Encode() return u.String() } // validateURL performs security validation on URLs to prevent SSRF attacks. // It checks for allowed schemes, validates hosts, and prevents access to private networks. // Parameters: // - urlStr: The URL string to validate. // // Returns: // - An error if the URL is invalid or poses security risks, nil if valid. func (t *TraefikOidc) validateURL(urlStr string) error { if urlStr == "" { return fmt.Errorf("empty URL") } u, err := url.Parse(urlStr) if err != nil { return fmt.Errorf("invalid URL format: %w", err) } return t.validateParsedURL(u) } // validateParsedURL validates a parsed URL structure for security. // It checks schemes, hosts, and paths to prevent malicious URLs. // Parameters: // - u: The parsed URL to validate. // // Returns: // - An error if the URL is invalid or dangerous, nil if safe. func (t *TraefikOidc) validateParsedURL(u *url.URL) error { allowedSchemes := map[string]bool{ "https": true, "http": true, } if !allowedSchemes[u.Scheme] { return fmt.Errorf("disallowed URL scheme: %s", u.Scheme) } if u.Scheme == "http" { t.logger.Debugf("Warning: Using HTTP scheme for URL: %s", u.String()) } if u.Host == "" { return fmt.Errorf("missing host in URL") } if err := t.validateHost(u.Host); err != nil { return fmt.Errorf("invalid host: %w", err) } if strings.Contains(u.Path, "..") { return fmt.Errorf("path traversal detected in URL path") } return nil } // validateDiscoveredEndpoint validates an endpoint URL obtained from the // provider's OIDC/OAuth2 discovery document before the plugin issues any // outbound request to it. A discovery document is attacker-influenced if the // provider is malicious or its TLS is broken, so an unvalidated endpoint is an // SSRF vector (e.g. jwks_uri or introspection_endpoint pointed at the cloud // metadata service 169.254.169.254 or an internal host). // // Empty endpoints are allowed (they are optional). Link-local (which covers the // 169.254.0.0/16 metadata range), multicast and unspecified addresses are // always rejected. Private addresses are rejected unless allowPrivateIPAddresses // is set. Loopback is rejected unless allowLoopback is true — which the caller // sets only when the operator-configured providerURL is itself loopback (local // development, in-cluster sidecars, tests), so production deployments pointed at // a real provider still block loopback SSRF. func (t *TraefikOidc) validateDiscoveredEndpoint(urlStr string, allowLoopback bool) error { if urlStr == "" { return nil } u, err := url.Parse(urlStr) if err != nil { return fmt.Errorf("invalid URL format: %w", err) } if u.Scheme != "https" && u.Scheme != "http" { return fmt.Errorf("disallowed URL scheme: %q", u.Scheme) } // Pin the discovered endpoint's scheme to the provider's own scheme. A // provider served over HTTPS must hand out HTTPS endpoints; a legacy or // poisoned discovery document that downgrades a credential-bearing // endpoint (client_secret, tokens) to plaintext HTTP would otherwise be // used as-is, shipping secrets over an unauthenticated channel (R146). if u.Scheme == "http" && t.providerUsesHTTPS() { return ErrDiscoveredEndpointSchemeDowngrade } if u.Host == "" { return fmt.Errorf("missing host in URL") } if ip := net.ParseIP(u.Hostname()); ip != nil { switch { case ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified(): return fmt.Errorf("endpoint host is a blocked address: %s", ip) case ip.IsLoopback() && !allowLoopback: return fmt.Errorf("endpoint host is a loopback address: %s", ip) case ip.IsPrivate() && !t.allowPrivateIPAddresses: return fmt.Errorf("endpoint host is a private address: %s", ip) } } else if err := t.validateHost(u.Hostname()); err != nil && !(allowLoopback && isLoopbackHost(u.Hostname())) { // Name-based hosts (localhost, metadata.google.internal, any // hostname) bypass the IP-literal checks above; run the hostname // SSRF guard so a discovery doc pointing at such a host is // rejected (R97). Loopback names (localhost) are permitted when // allowLoopback is set, mirroring the IP-literal branch; hard // metadata-service names (metadata.google.internal) are never // allowed because they are not loopback. return fmt.Errorf("discovered endpoint host is not allowed: %w", err) } if strings.Contains(u.Path, "..") { return fmt.Errorf("path traversal detected in URL path") } return nil } // sameHost reports whether two URLs share the same host:port (case-insensitive). // Used to pin the credential-bearing introspection endpoint to the operator- // configured provider so a poisoned discovery document cannot redirect the // client secret to an attacker-controlled host. // providerUsesHTTPS reports whether the operator-configured provider URL is // served over HTTPS. Used to pin discovered endpoint schemes. func (t *TraefikOidc) providerUsesHTTPS() bool { t.metadataMu.RLock() defer t.metadataMu.RUnlock() return t.providerURL != "" && strings.HasPrefix(strings.ToLower(t.providerURL), "https://") } func sameHost(a, b string) bool { ua, erra := url.Parse(a) ub, errb := url.Parse(b) if erra != nil || errb != nil || ua.Host == "" || ub.Host == "" { return false } return strings.EqualFold(ua.Host, ub.Host) } // validateHost validates a hostname or IP address for security. // It prevents access to localhost, private networks, and known metadata endpoints. // When allowPrivateIPAddresses is enabled, private IP checks are skipped. // Parameters: // - host: The host string to validate (may include port). // // Returns: // - An error if the host is dangerous or not allowed, nil if safe. func (t *TraefikOidc) validateHost(host string) error { hostname := host if strings.Contains(host, ":") { var err error hostname, _, err = net.SplitHostPort(host) if err != nil { return fmt.Errorf("invalid host format: %w", err) } } ip := net.ParseIP(hostname) if ip != nil { // 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()) } // 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()) } if ip.IsUnspecified() || ip.IsMulticast() { return fmt.Errorf("access to unspecified or multicast IP addresses is not allowed: %s", ip.String()) } } // 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 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 nil } // redactCallbackURL returns the callback URL with the one-time // authorization code (and state) query values redacted. The single-use // code is a quiet credential: logging the full callback URL (as the // per-request Debug line and the CSRF-failure Error line did) would // publish it into the log pipeline (R153). Malformed URLs are returned // unchanged. func redactCallbackURL(rawURL string) string { u, err := url.Parse(rawURL) if err != nil { return rawURL } q := u.Query() for _, k := range []string{"code", "state"} { if q.Get(k) != "" { q.Set(k, "[REDACTED]") } } u.RawQuery = q.Encode() return u.String() }