Files
traefikoidc/url_helpers.go
T
lukaszraczylo 78db86b455 fix(security): treat trailing-dot hosts as their blocked names
validateHost and isLoopbackHost matched hostnames by exact string. The
spellings localhost. and metadata.google.internal. passed the SSRF
check. The check now drops the root dot and any IPv6 zone id first.
A bare IPv6 literal without a port no longer fails the host check.
2026-11-03 00:42:00 +00:00

550 lines
20 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 (
"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)
}
// canonicalHost drops the root dot ("localhost." is localhost) and an IPv6
// zone id so the blocklists see one spelling per host.
func canonicalHost(host string) string {
if i := strings.IndexByte(host, '%'); i > 0 && net.ParseIP(host[:i]) != nil {
host = host[:i]
}
return strings.TrimRight(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 := canonicalHost(host)
if net.ParseIP(hostname) == nil && strings.Contains(hostname, ":") {
var err error
hostname, _, err = net.SplitHostPort(hostname)
if err != nil {
return fmt.Errorf("invalid host format: %w", err)
}
hostname = canonicalHost(hostname)
}
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()
}