Files
traefikoidc/url_helpers.go
T
lukaszraczylo 8640451877 fix(auth): harden OIDC flow, token validation, session, and bearer handling
- replay entry covers exp + clock-skew window; callback GET-only (RFC 6749)
- callback rate-limit returns 429 + Retry-After; panic-recovery sets no-store
- cross-chunk integrity for chunked tokens; bounded chunked-token read scan
- HTTPError classification so 5xx/429 token-endpoint responses retry
- redaction of token/code/state in logs; per-source external auth gate
2026-08-14 16:16:26 +01:00

524 lines
19 KiB
Go

// 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 (
"fmt"
"net"
"net/url"
"path"
"strings"
)
// 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/audience under metadataMu: DCR rewrites them at
// runtime (R137).
clientID, _, _, audience, _ := 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)
// Add audience parameter for custom API audiences (e.g., Auth0 APIs)
// This allows access tokens to have the correct audience claim
// Only add if audience is configured and different from client_id
// ID tokens will always have aud=client_id per OIDC spec
if audience != "" && audience != clientID {
params.Set("audience", audience)
t.logger.Debugf("Adding audience parameter to authorize URL: %s", audience)
}
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 fmt.Errorf("discovered endpoint uses plaintext http while the provider is https; refusing scheme downgrade")
}
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()
}