mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-12 04:04:26 +00:00
The SSE and WebSocket bypass does not check the token expiry. A session stays usable for streaming until the session cookie expires, also after the IdP disables the user. The bypass now forwards the configured headers (#162), so this gap also gives the proxy secret to such a session. Add the streamingRefresh option. The default is false. When it is true and the access token has expired, the bypass refreshes the session. It uses the ID token expiry if the access token is opaque. If the IdP rejects the refresh with invalid_grant, the request gets 401. If the IdP cannot be reached, the request is forwarded as before. The bypass waits for the full coordinated refresh. A shorter wait can drop a rotated refresh token, and the next refresh then logs the user out. applyBypassUserHeaders now takes the ResponseWriter, so a refresh can write the new session cookie. Add a yaegi check for the invalid_grant path through the real token endpoint and RefreshCoordinator. Claude-Session: https://claude.ai/code/session_01G1d1n1BrHHHw8dBUkbiBPC
1522 lines
63 KiB
Go
1522 lines
63 KiB
Go
package traefikoidc
|
|
|
|
import (
|
|
"crypto/x509"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"math"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// TemplatedHeader represents a custom HTTP header with a templated value.
|
|
// The value can contain template expressions that will be evaluated for each
|
|
// authenticated request, such as {{.claims.email}} or {{.accessToken}}.
|
|
type TemplatedHeader struct {
|
|
// Name is the HTTP header name to set (e.g., "X-Forwarded-Email")
|
|
Name string `json:"name"`
|
|
|
|
// Value is the template string for the header value
|
|
// Example: "{{.claims.email}}", "Bearer {{.accessToken}}"
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
// Config holds the configuration for the OIDC middleware.
|
|
// It provides all necessary settings to configure OpenID Connect authentication
|
|
// with various providers like Auth0, Logto, or any standard OIDC provider.
|
|
type Config struct {
|
|
DynamicClientRegistration *DynamicClientRegistrationConfig `json:"dynamicClientRegistration,omitempty"`
|
|
Redis *RedisConfig `json:"redis,omitempty"`
|
|
HTTPClient *http.Client `json:"-"`
|
|
SecurityHeaders *SecurityHeadersConfig `json:"securityHeaders,omitempty"`
|
|
PostLogoutRedirectURI string `json:"postLogoutRedirectURI"`
|
|
LogLevel string `json:"logLevel"`
|
|
LogoutURL string `json:"logoutURL"`
|
|
ClientID string `json:"clientID"`
|
|
ClientSecret string `json:"clientSecret"`
|
|
Audience string `json:"audience,omitempty"`
|
|
CookiePrefix string `json:"cookiePrefix"`
|
|
CallbackURL string `json:"callbackURL"`
|
|
SessionEncryptionKey string `json:"sessionEncryptionKey"`
|
|
ProviderURL string `json:"providerURL"`
|
|
RevocationURL string `json:"revocationURL"`
|
|
UserIdentifierClaim string `json:"userIdentifierClaim,omitempty"`
|
|
GroupClaimName string `json:"groupClaimName,omitempty"`
|
|
RoleClaimName string `json:"roleClaimName,omitempty"`
|
|
CookieDomain string `json:"cookieDomain"`
|
|
OIDCEndSessionURL string `json:"oidcEndSessionURL"`
|
|
// IntrospectionURL overrides the RFC 7662 token introspection endpoint.
|
|
// Set it when the IdP omits introspection_endpoint from discovery. It
|
|
// takes precedence over the discovered value.
|
|
IntrospectionURL string `json:"introspectionURL"`
|
|
Scopes []string `json:"scopes"`
|
|
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
|
|
ExcludedURLs []string `json:"excludedURLs"`
|
|
AllowedUserDomains []string `json:"allowedUserDomains"`
|
|
AllowedUsers []string `json:"allowedUsers"`
|
|
Headers []TemplatedHeader `json:"headers"`
|
|
// AllowedClaims extends the built-in claims whitelist that header value
|
|
// templates may emit. Add the exact claim name (e.g. "employee_id") to allow
|
|
// {{.Claims.employee_id}} / {{get .Claims "employee_id"}}. Applies to both
|
|
// template validation and the runtime get helper. Built-in claims (email,
|
|
// groups, roles, realm_access, ...) need not be listed.
|
|
AllowedClaims []string `json:"allowedClaims,omitempty"`
|
|
ExtraAuthParams map[string]string `json:"extraAuthParams,omitempty"`
|
|
// Resource declares an RFC 8707 resource indicator: an alternative to
|
|
// Audience for IdPs that support it. strictAudienceValidation applies
|
|
// the same way regardless of which of the two is set; only the request
|
|
// made to the IdP differs. Only a single resource is supported. See
|
|
// docs/RFC8707.md for exact request behavior, why multiple resources
|
|
// aren't, and current provider support. When Resource is set and
|
|
// Audience is unset, Audience defaults to Resource for access-token
|
|
// validation; this does not affect what is sent on the wire — see
|
|
// TraefikOidc.explicitAudience.
|
|
Resource string `json:"resource,omitempty"`
|
|
RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"`
|
|
// MaxRefreshTokenAgeSeconds is a heuristic upper bound on the lifetime of
|
|
// a stored refresh token. Once the token has been in the session longer
|
|
// than this, requests treat it as expired up-front - returning 401 to
|
|
// AJAX callers and triggering full re-auth on navigations - instead of
|
|
// hammering the IdP with grants that will only fail with invalid_grant.
|
|
// IdPs do not expose RT TTL on the wire, so this is intentionally a
|
|
// conservative heuristic; tune to match your provider configuration.
|
|
// Default 21600 (6h). Set to 0 to disable the check.
|
|
MaxRefreshTokenAgeSeconds int `json:"maxRefreshTokenAgeSeconds"`
|
|
SessionMaxAge int `json:"sessionMaxAge"`
|
|
RateLimit int `json:"rateLimit"`
|
|
// PerSourceLoginRateLimit throttles OIDC authentication events
|
|
// (authorization-code callback + login initiation) per client source IP,
|
|
// for external (non-internal) sources only. Internal / loopback /
|
|
// link-local sources (reverse proxies, in-cluster callers) are trusted
|
|
// and never throttled. Value = max auth events per minute per external
|
|
// source; 0 disables. See per_source_ratelimit.go (R185).
|
|
PerSourceLoginRateLimit int `json:"perSourceLoginRateLimit,omitempty"`
|
|
OverrideScopes bool `json:"overrideScopes"`
|
|
DisableReplayDetection bool `json:"disableReplayDetection,omitempty"`
|
|
RequireTokenIntrospection bool `json:"requireTokenIntrospection,omitempty"`
|
|
AllowOpaqueTokens bool `json:"allowOpaqueTokens,omitempty"`
|
|
StrictAudienceValidation bool `json:"strictAudienceValidation,omitempty"`
|
|
EnablePKCE bool `json:"enablePKCE"`
|
|
ForceHTTPS bool `json:"forceHTTPS"`
|
|
AllowPrivateIPAddresses bool `json:"allowPrivateIPAddresses,omitempty"`
|
|
// AllowUnauthenticatedPreflight restores the pre-FIX-02 CORS preflight
|
|
// bypass: when true, a genuine preflight (OPTIONS carrying both Origin
|
|
// and Access-Control-Request-Method) is forwarded to the backend without
|
|
// a session check, with its response body discarded
|
|
// (optionsPreflightWriter, middleware.go) so a forged preflight still
|
|
// cannot read a protected resource's body. Default false: the released
|
|
// behavior requires authentication for every OPTIONS request, preflight
|
|
// or not, like any other method. Enable only if your backend itself
|
|
// answers CORS preflights and must see them unauthenticated.
|
|
AllowUnauthenticatedPreflight bool `json:"allowUnauthenticatedPreflight,omitempty"`
|
|
// StreamingRefresh refreshes an expired session on the SSE/WebSocket
|
|
// bypass and rejects the request when the IdP revokes the grant.
|
|
// Default false: the bypass trusts the session cookie until it expires.
|
|
StreamingRefresh bool `json:"streamingRefresh,omitempty"`
|
|
MinimalHeaders bool `json:"minimalHeaders,omitempty"`
|
|
StripAuthCookies bool `json:"stripAuthCookies,omitempty"`
|
|
// CookiePath restricts session cookies to a specific path prefix instead of "/".
|
|
// When traefikoidc protects some but not all paths on a domain, set this to the
|
|
// middleware's path prefix (e.g. "/app-protegido") so the browser does not send
|
|
// the OIDC session cookies to unprotected paths — preventing "Request Header
|
|
// Or Cookie Too Large" (431) errors on those paths.
|
|
// Default "/" (all paths, current behavior).
|
|
CookiePath string `json:"cookiePath,omitempty"`
|
|
EnableBackchannelLogout bool `json:"enableBackchannelLogout,omitempty"`
|
|
EnableFrontchannelLogout bool `json:"enableFrontchannelLogout,omitempty"`
|
|
BackchannelLogoutURL string `json:"backchannelLogoutURL,omitempty"`
|
|
FrontchannelLogoutURL string `json:"frontchannelLogoutURL,omitempty"`
|
|
// CACertPath is an optional filesystem path to a PEM-encoded CA bundle used
|
|
// to verify the OIDC provider's TLS certificate. Use this when the provider
|
|
// is signed by an internal/private CA that is not in the system trust store.
|
|
CACertPath string `json:"caCertPath,omitempty"`
|
|
// CACertPEM is an optional inline PEM-encoded CA bundle, equivalent to
|
|
// CACertPath but supplied directly in the middleware configuration. Both
|
|
// may be set; certificates from both sources are combined.
|
|
CACertPEM string `json:"caCertPEM,omitempty"`
|
|
// InsecureSkipVerify disables TLS certificate verification for the OIDC
|
|
// provider. Intended ONLY for local development against self-signed
|
|
// providers. Enabling this in production is a security hole — prefer
|
|
// CACertPath/CACertPEM. Emits a loud warning at startup.
|
|
InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"`
|
|
|
|
// ClientAuthMethod selects the OAuth 2.0 client authentication method used
|
|
// at the token / revocation / introspection endpoints. Supported values:
|
|
//
|
|
// - "client_secret_post" (default, current behavior): clientSecret is
|
|
// sent in the request body alongside client_id.
|
|
// - "private_key_jwt" (RFC 7523 §2.2): the plugin signs a short-lived JWT
|
|
// assertion with a configured private key and sends it as
|
|
// client_assertion. Use this when your IdP enforces short-lived secrets
|
|
// or mandates secretless client auth (Entra ID, Okta, Auth0, Keycloak).
|
|
//
|
|
// When set to "private_key_jwt", clientSecret may be left empty and one of
|
|
// clientAssertionPrivateKey / clientAssertionKeyPath must be configured.
|
|
ClientAuthMethod string `json:"clientAuthMethod,omitempty"`
|
|
|
|
// ClientAssertionPrivateKey is an inline PEM-encoded private key used to
|
|
// sign client_assertion JWTs. Mutually exclusive with
|
|
// ClientAssertionKeyPath. Supports PKCS#8, PKCS#1 (RSA), and SEC1 (EC).
|
|
ClientAssertionPrivateKey string `json:"clientAssertionPrivateKey,omitempty"`
|
|
|
|
// ClientAssertionKeyPath is a filesystem path to a PEM-encoded private key,
|
|
// equivalent to ClientAssertionPrivateKey but loaded from disk.
|
|
ClientAssertionKeyPath string `json:"clientAssertionKeyPath,omitempty"`
|
|
|
|
// ClientAssertionKeyID is the JWK key id (kid) advertised in the JWS
|
|
// header. Required when using private_key_jwt so the IdP can locate the
|
|
// matching public key registered for the client.
|
|
ClientAssertionKeyID string `json:"clientAssertionKeyID,omitempty"`
|
|
|
|
// ClientAssertionAlg is the JWS signing algorithm. Defaults to RS256.
|
|
// Supported: RS256/384/512, PS256/384/512, ES256/384/512.
|
|
ClientAssertionAlg string `json:"clientAssertionAlg,omitempty"`
|
|
|
|
// --- Bearer-token auth (opt-in M2M path) ---
|
|
|
|
// EnableBearerAuth turns on the Authorization: Bearer <jwt> auth path.
|
|
// Default false. When true, Audience MUST be set or startup fails. The
|
|
// bearer path is M2M-only: it accepts validated access-token JWTs, rejects
|
|
// ID tokens, and forwards principal headers downstream without creating a
|
|
// cookie session. See docs/BEARER_AUTH.md for the threat model.
|
|
EnableBearerAuth bool `json:"enableBearerAuth,omitempty"`
|
|
// BearerIdentifierClaim names the JWT claim used as the principal identifier
|
|
// on the bearer-token auth path. Default "sub". Decoupled from
|
|
// UserIdentifierClaim (which defaults to "email" and drives the cookie path)
|
|
// so M2M bearer flow never accidentally relies on an unverified email.
|
|
BearerIdentifierClaim string `json:"bearerIdentifierClaim,omitempty"`
|
|
// StripAuthorizationHeader removes the Authorization header from the
|
|
// forwarded request after successful bearer auth, so downstream services
|
|
// never see the raw token. Default true. Disable only when a downstream
|
|
// explicitly needs to re-validate the bearer.
|
|
StripAuthorizationHeader bool `json:"stripAuthorizationHeader,omitempty"`
|
|
// BearerEmitWWWAuthenticate controls whether 401 responses on the bearer
|
|
// path include a WWW-Authenticate: Bearer error="invalid_token" hint per
|
|
// RFC 6750 §3. Default true. Disable to reduce reconnaissance signal.
|
|
BearerEmitWWWAuthenticate bool `json:"bearerEmitWWWAuthenticate,omitempty"`
|
|
// BearerOverridesCookie controls precedence when both Authorization:
|
|
// Bearer and a session cookie are present. Default false: cookie wins
|
|
// (safer against browser/extension/proxy bearer injection). Set true for
|
|
// the bearer-wins convention used by AWS/GCP/Kubernetes API gateways.
|
|
BearerOverridesCookie bool `json:"bearerOverridesCookie,omitempty"`
|
|
// MaxTokenAgeSeconds caps how old (iat-based) a bearer token may be.
|
|
// Default 86400 (24h). Bounds clock-manipulation tokens with implausibly
|
|
// distant iat values.
|
|
MaxTokenAgeSeconds int64 `json:"maxTokenAgeSeconds,omitempty"`
|
|
// MaxIdentifierLength bounds the post-sanitisation length of the bearer
|
|
// principal identifier (the value injected as X-Forwarded-User). Default
|
|
// 256.
|
|
MaxIdentifierLength int `json:"maxIdentifierLength,omitempty"`
|
|
// BearerFailureThreshold is the number of consecutive 401s from one
|
|
// source IP within BearerFailureWindowSeconds that trips the throttle.
|
|
// Default 20.
|
|
BearerFailureThreshold int `json:"bearerFailureThreshold,omitempty"`
|
|
// BearerFailureWindowSeconds is the rolling window (seconds) over which
|
|
// 401s are counted for throttling. Default 60.
|
|
BearerFailureWindowSeconds int `json:"bearerFailureWindowSeconds,omitempty"`
|
|
// BearerFailurePenaltySeconds is how long an IP is parked in the 429
|
|
// penalty box after BearerFailureThreshold is exceeded. Default 60.
|
|
BearerFailurePenaltySeconds int `json:"bearerFailurePenaltySeconds,omitempty"`
|
|
}
|
|
|
|
// loadCACertPool assembles an x509.CertPool from CACertPath and CACertPEM.
|
|
// Returns (nil, nil) when neither is configured — callers should fall back to
|
|
// the system trust store. Returns a descriptive error if a PEM source is
|
|
// configured but contains no parseable certificates, so misconfigurations
|
|
// surface at startup rather than as unexplained TLS failures at runtime.
|
|
func (c *Config) loadCACertPool() (*x509.CertPool, error) {
|
|
if c.CACertPath == "" && c.CACertPEM == "" {
|
|
return nil, nil
|
|
}
|
|
pool := x509.NewCertPool()
|
|
if c.CACertPath != "" {
|
|
data, err := os.ReadFile(c.CACertPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read caCertPath %q: %w", c.CACertPath, err)
|
|
}
|
|
if !pool.AppendCertsFromPEM(data) {
|
|
return nil, fmt.Errorf("caCertPath %q: no valid PEM certificates found", c.CACertPath)
|
|
}
|
|
}
|
|
if c.CACertPEM != "" {
|
|
if !pool.AppendCertsFromPEM([]byte(c.CACertPEM)) {
|
|
return nil, fmt.Errorf("caCertPEM: no valid PEM certificates found")
|
|
}
|
|
}
|
|
return pool, nil
|
|
}
|
|
|
|
// RedisConfig configures Redis cache backend settings for distributed caching.
|
|
// All fields support both JSON and YAML configuration for compatibility with Traefik's
|
|
// dynamic configuration (labels, YAML files, etc.)
|
|
type RedisConfig struct {
|
|
KeyPrefix string `json:"keyPrefix" yaml:"keyPrefix"`
|
|
Address string `json:"address" yaml:"address"`
|
|
Password string `json:"password,omitempty" yaml:"password,omitempty"`
|
|
CacheMode string `json:"cacheMode" yaml:"cacheMode"`
|
|
WriteTimeout int `json:"writeTimeout" yaml:"writeTimeout"`
|
|
CircuitBreakerThreshold int `json:"circuitBreakerThreshold" yaml:"circuitBreakerThreshold"`
|
|
ConnectTimeout int `json:"connectTimeout" yaml:"connectTimeout"`
|
|
ReadTimeout int `json:"readTimeout" yaml:"readTimeout"`
|
|
PoolSize int `json:"poolSize" yaml:"poolSize"`
|
|
HealthCheckInterval int `json:"healthCheckInterval" yaml:"healthCheckInterval"`
|
|
CircuitBreakerTimeout int `json:"circuitBreakerTimeout" yaml:"circuitBreakerTimeout"`
|
|
DB int `json:"db" yaml:"db"`
|
|
HybridL1Size int `json:"hybridL1Size" yaml:"hybridL1Size"`
|
|
HybridL1MemoryMB int64 `json:"hybridL1MemoryMB" yaml:"hybridL1MemoryMB"`
|
|
Enabled bool `json:"enabled" yaml:"enabled"`
|
|
EnableCircuitBreaker bool `json:"enableCircuitBreaker" yaml:"enableCircuitBreaker"`
|
|
TLSSkipVerify bool `json:"tlsSkipVerify" yaml:"tlsSkipVerify"`
|
|
EnableHealthCheck bool `json:"enableHealthCheck" yaml:"enableHealthCheck"`
|
|
EnableTLS bool `json:"enableTLS" yaml:"enableTLS"`
|
|
}
|
|
|
|
// DynamicClientRegistrationConfig configures OIDC Dynamic Client Registration (RFC 7591)
|
|
type DynamicClientRegistrationConfig struct {
|
|
ClientMetadata *ClientRegistrationMetadata `json:"clientMetadata,omitempty"`
|
|
InitialAccessToken string `json:"initialAccessToken,omitempty"`
|
|
RegistrationEndpoint string `json:"registrationEndpoint,omitempty"`
|
|
CredentialsFile string `json:"credentialsFile,omitempty"`
|
|
// StorageBackend specifies where to store DCR credentials: "file", "redis", or "auto"
|
|
// - "file": Use file-based storage (default for backward compatibility)
|
|
// - "redis": Use Redis exclusively (fails if Redis unavailable)
|
|
// - "auto": Use Redis if available, fallback to file (default)
|
|
StorageBackend string `json:"storageBackend,omitempty"`
|
|
// RedisKeyPrefix is the prefix for Redis keys when using Redis storage (default: "dcr:creds:")
|
|
RedisKeyPrefix string `json:"redisKeyPrefix,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
PersistCredentials bool `json:"persistCredentials"`
|
|
}
|
|
|
|
// ClientRegistrationMetadata contains client metadata for dynamic registration (RFC 7591)
|
|
type ClientRegistrationMetadata struct {
|
|
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
|
TOSURI string `json:"tos_uri,omitempty"`
|
|
Scope string `json:"scope,omitempty"`
|
|
ApplicationType string `json:"application_type,omitempty"`
|
|
SubjectType string `json:"subject_type,omitempty"`
|
|
ClientName string `json:"client_name,omitempty"`
|
|
LogoURI string `json:"logo_uri,omitempty"`
|
|
ClientURI string `json:"client_uri,omitempty"`
|
|
PolicyURI string `json:"policy_uri,omitempty"`
|
|
JWKSURI string `json:"jwks_uri,omitempty"`
|
|
ResponseTypes []string `json:"response_types,omitempty"`
|
|
Contacts []string `json:"contacts,omitempty"`
|
|
RedirectURIs []string `json:"redirect_uris"`
|
|
DefaultACRValues []string `json:"default_acr_values,omitempty"`
|
|
GrantTypes []string `json:"grant_types,omitempty"`
|
|
DefaultMaxAge int `json:"default_max_age,omitempty"`
|
|
RequireAuthTime bool `json:"require_auth_time,omitempty"`
|
|
}
|
|
|
|
// SecurityHeadersConfig configures security headers for the plugin
|
|
type SecurityHeadersConfig struct {
|
|
CustomHeaders map[string]string `json:"customHeaders,omitempty"`
|
|
PermissionsPolicy string `json:"permissionsPolicy,omitempty"`
|
|
Profile string `json:"profile"`
|
|
ContentSecurityPolicy string `json:"contentSecurityPolicy,omitempty"`
|
|
CrossOriginResourcePolicy string `json:"crossOriginResourcePolicy,omitempty"`
|
|
CrossOriginOpenerPolicy string `json:"crossOriginOpenerPolicy,omitempty"`
|
|
CrossOriginEmbedderPolicy string `json:"crossOriginEmbedderPolicy,omitempty"`
|
|
FrameOptions string `json:"frameOptions,omitempty"`
|
|
ContentTypeOptions string `json:"contentTypeOptions,omitempty"`
|
|
XSSProtection string `json:"xssProtection,omitempty"`
|
|
ReferrerPolicy string `json:"referrerPolicy,omitempty"`
|
|
CORSAllowedHeaders []string `json:"corsAllowedHeaders,omitempty"`
|
|
CORSAllowedOrigins []string `json:"corsAllowedOrigins,omitempty"`
|
|
CORSAllowedMethods []string `json:"corsAllowedMethods,omitempty"`
|
|
StrictTransportSecurityMaxAge int `json:"strictTransportSecurityMaxAge"`
|
|
CORSMaxAge int `json:"corsMaxAge"`
|
|
StrictTransportSecurityPreload bool `json:"strictTransportSecurityPreload"`
|
|
StrictTransportSecuritySubdomains bool `json:"strictTransportSecuritySubdomains"`
|
|
CORSEnabled bool `json:"corsEnabled"`
|
|
Enabled bool `json:"enabled"`
|
|
CORSAllowCredentials bool `json:"corsAllowCredentials"`
|
|
StrictTransportSecurity bool `json:"strictTransportSecurity"`
|
|
DisableServerHeader bool `json:"disableServerHeader"`
|
|
DisablePoweredByHeader bool `json:"disablePoweredByHeader"`
|
|
}
|
|
|
|
const (
|
|
// DefaultRateLimit defines the default rate limit for requests per second
|
|
DefaultRateLimit = 100
|
|
|
|
// MinRateLimit defines the minimum allowed rate limit to prevent DOS
|
|
MinRateLimit = 10
|
|
|
|
// DefaultLogLevel defines the default logging level
|
|
DefaultLogLevel = "info"
|
|
|
|
// MinSessionEncryptionKeyLength defines the minimum length for session encryption key
|
|
MinSessionEncryptionKeyLength = 32
|
|
)
|
|
|
|
// CreateConfig creates a new Config with secure default values.
|
|
// Default values are set for optional fields:
|
|
// - Scopes: ["openid", "profile", "email"]
|
|
// - LogLevel: "info"
|
|
// - LogoutURL: CallbackURL + "/logout"
|
|
// - RateLimit: 100 requests per second
|
|
// - PostLogoutRedirectURI: "/"
|
|
// - ForceHTTPS: true (for security)
|
|
// - EnablePKCE: false (PKCE is opt-in)
|
|
// - Redis: nil (disabled by default, can be configured via Traefik config or env vars)
|
|
//
|
|
// CreateConfig initializes a new Config struct with default values for optional fields.
|
|
// It sets default scopes, log level, rate limit, enables ForceHTTPS, and sets the
|
|
// default refresh grace period. Required fields like ProviderURL, ClientID, ClientSecret,
|
|
// CallbackURL, and SessionEncryptionKey must be set explicitly after creation.
|
|
// Redis configuration can be provided through Traefik's dynamic configuration or
|
|
// as a fallback through environment variables.
|
|
//
|
|
// Returns:
|
|
// - A pointer to a new Config struct with default settings applied.
|
|
func CreateConfig() *Config {
|
|
c := &Config{
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
LogLevel: DefaultLogLevel,
|
|
RateLimit: DefaultRateLimit,
|
|
ForceHTTPS: true, // Secure by default
|
|
EnablePKCE: false, // PKCE is opt-in
|
|
OverrideScopes: false, // Default to appending scopes, not overriding
|
|
RefreshGracePeriodSeconds: 60, // Default grace period of 60 seconds
|
|
MaxRefreshTokenAgeSeconds: 21600, // 6h - conservative heuristic, see field doc
|
|
SecurityHeaders: createDefaultSecurityConfig(),
|
|
Redis: nil, // Redis is disabled by default, configure via Traefik or env vars
|
|
|
|
// Bearer-auth defaults. EnableBearerAuth=false leaves the feature
|
|
// dormant; the rest are values that apply only when bearer is enabled.
|
|
EnableBearerAuth: false,
|
|
BearerIdentifierClaim: "sub",
|
|
StripAuthorizationHeader: true,
|
|
BearerEmitWWWAuthenticate: true,
|
|
BearerOverridesCookie: false,
|
|
MaxTokenAgeSeconds: 86400,
|
|
MaxIdentifierLength: 256,
|
|
BearerFailureThreshold: 20,
|
|
BearerFailureWindowSeconds: 60,
|
|
BearerFailurePenaltySeconds: 60,
|
|
}
|
|
|
|
return c
|
|
}
|
|
|
|
// createDefaultSecurityConfig creates a default security headers configuration
|
|
func createDefaultSecurityConfig() *SecurityHeadersConfig {
|
|
return &SecurityHeadersConfig{
|
|
Enabled: true,
|
|
Profile: "default",
|
|
|
|
// Default security headers
|
|
StrictTransportSecurity: true,
|
|
StrictTransportSecurityMaxAge: 31536000, // 1 year
|
|
StrictTransportSecuritySubdomains: true,
|
|
StrictTransportSecurityPreload: true,
|
|
|
|
FrameOptions: "DENY",
|
|
ContentTypeOptions: "nosniff",
|
|
XSSProtection: "1; mode=block",
|
|
ReferrerPolicy: "strict-origin-when-cross-origin",
|
|
|
|
// CORS disabled by default
|
|
CORSEnabled: false,
|
|
CORSAllowedMethods: []string{"GET", "POST", "OPTIONS"},
|
|
CORSAllowedHeaders: []string{"Authorization", "Content-Type"},
|
|
CORSAllowCredentials: false,
|
|
CORSMaxAge: 86400, // 24 hours
|
|
|
|
// Security features
|
|
DisableServerHeader: true,
|
|
DisablePoweredByHeader: true,
|
|
}
|
|
}
|
|
|
|
// Validate checks the configuration settings for validity.
|
|
// It ensures that required fields (ProviderURL, CallbackURL, ClientID, ClientSecret, SessionEncryptionKey)
|
|
// are present and that URLs are well-formed (HTTPS where required). It also validates
|
|
// the session key length, log level, rate limit, and refresh grace period.
|
|
//
|
|
// Returns:
|
|
// - nil if the configuration is valid.
|
|
// - An error describing the first validation failure encountered.
|
|
//
|
|
// ApplyEnvFallbacks applies environment variable values as fallbacks for
|
|
// empty top-level OIDC config fields. Mirrors RedisConfig.ApplyEnvFallbacks:
|
|
// the plugin dynamic configuration (Traefik) takes precedence, env vars only
|
|
// fill empty fields. The example deployment documents OIDC_CLIENT_ID /
|
|
// OIDC_CLIENT_SECRET / OIDC_PROVIDER_URL as optional fallbacks; previously
|
|
// those vars were documented but never read, so an operator following the
|
|
// docs got a silent no-op and then "providerURL is required" (R180).
|
|
func (c *Config) ApplyEnvFallbacks() {
|
|
if c.ClientID == "" {
|
|
if v := os.Getenv("OIDC_CLIENT_ID"); v != "" {
|
|
c.ClientID = v
|
|
}
|
|
}
|
|
if c.ClientSecret == "" {
|
|
if v := os.Getenv("OIDC_CLIENT_SECRET"); v != "" {
|
|
c.ClientSecret = v
|
|
}
|
|
}
|
|
if c.ProviderURL == "" {
|
|
if v := os.Getenv("OIDC_PROVIDER_URL"); v != "" {
|
|
c.ProviderURL = v
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Config) Validate() error {
|
|
// Apply OIDC env fallbacks BEFORE the required-field checks: an
|
|
// operator supplying these via env (the documented pattern) has empty
|
|
// fields at config load, so without this the "providerURL is
|
|
// required" / "clientID is required" guards would fire and reject a
|
|
// valid env-supplied configuration (R180).
|
|
c.ApplyEnvFallbacks()
|
|
// Validate provider URL
|
|
if c.ProviderURL == "" {
|
|
return fmt.Errorf("providerURL is required")
|
|
}
|
|
if !isValidSecureURL(c.ProviderURL) {
|
|
return fmt.Errorf("providerURL must be a valid HTTPS URL")
|
|
}
|
|
|
|
// Validate callback URL
|
|
if c.CallbackURL == "" {
|
|
return fmt.Errorf("callbackURL is required")
|
|
}
|
|
if !strings.HasPrefix(c.CallbackURL, "/") {
|
|
return fmt.Errorf("callbackURL must start with /")
|
|
}
|
|
// The callback is matched by exact request-path comparison
|
|
// (req.URL.Path == redirURLPath) and used verbatim as redirect_uri. A
|
|
// query string or fragment in the path config part never matches the
|
|
// request path and corrupts redirect_uri (buildFullURL), causing an
|
|
// endless re-auth loop. Reject it at config time.
|
|
if strings.ContainsAny(c.CallbackURL, "?#") {
|
|
return fmt.Errorf("callbackURL must not contain a query string or fragment")
|
|
}
|
|
|
|
// LogoutURL is matched by exact request-path comparison
|
|
// (logout.go handleLogout) and used to build the RP-initiated
|
|
// end-session URL. An empty value is allowed (defaults to
|
|
// CallbackURL + "/logout"), but a provided value must be a bare
|
|
// path: a query string or fragment, or a relative URI, silently
|
|
// breaks RP-initiated logout with no runtime error (R134).
|
|
if c.LogoutURL != "" {
|
|
if !strings.HasPrefix(c.LogoutURL, "/") {
|
|
return fmt.Errorf("logoutURL must start with /")
|
|
}
|
|
if strings.ContainsAny(c.LogoutURL, "?#") {
|
|
return fmt.Errorf("logoutURL must not contain a query string or fragment")
|
|
}
|
|
}
|
|
|
|
// Validate client credentials. With Dynamic Client Registration (RFC 7591)
|
|
// enabled, clientID (and typically clientSecret) are not known at
|
|
// construction time — the provider provisions them at registration. Skip
|
|
// these checks in that case; the providerURL / callbackURL / session-key
|
|
// checks above and below still run.
|
|
dcrEnabled := c.DynamicClientRegistration != nil && c.DynamicClientRegistration.Enabled
|
|
if !(dcrEnabled && c.ClientID == "") {
|
|
if c.ClientID == "" {
|
|
return fmt.Errorf("clientID is required")
|
|
}
|
|
authMethod := c.ClientAuthMethod
|
|
if authMethod == "" {
|
|
authMethod = "client_secret_post"
|
|
}
|
|
switch authMethod {
|
|
case "client_secret_post", "client_secret_basic":
|
|
if c.ClientSecret == "" {
|
|
return fmt.Errorf("clientSecret is required when clientAuthMethod is %q", authMethod)
|
|
}
|
|
case "private_key_jwt":
|
|
if c.ClientAssertionPrivateKey == "" && c.ClientAssertionKeyPath == "" {
|
|
return fmt.Errorf("clientAssertionPrivateKey or clientAssertionKeyPath is required when clientAuthMethod is private_key_jwt")
|
|
}
|
|
if c.ClientAssertionPrivateKey != "" && c.ClientAssertionKeyPath != "" {
|
|
return fmt.Errorf("only one of clientAssertionPrivateKey or clientAssertionKeyPath may be set")
|
|
}
|
|
if c.ClientAssertionKeyID == "" {
|
|
return fmt.Errorf("clientAssertionKeyID is required when clientAuthMethod is private_key_jwt")
|
|
}
|
|
if c.ClientAssertionAlg != "" && !isSupportedClientAssertionAlg(c.ClientAssertionAlg) {
|
|
return fmt.Errorf("clientAssertionAlg %q is not supported (use RS256/384/512, PS256/384/512, or ES256/384/512)", c.ClientAssertionAlg)
|
|
}
|
|
default:
|
|
return fmt.Errorf("clientAuthMethod %q is not supported", authMethod)
|
|
}
|
|
}
|
|
|
|
// The token-introspection path always authenticates with client_secret_basic
|
|
// (token_introspection.go SetBasicAuth), regardless of clientAuthMethod.
|
|
// Reject the validated-but-broken config where introspection is enabled but
|
|
// no client secret is supplied (e.g. private_key_jwt with no secret), which
|
|
// would otherwise send empty Basic credentials and fail every introspection.
|
|
// allowOpaqueTokens also routes opaque tokens through the same
|
|
// client_secret_basic introspection, so it must be covered too (R117).
|
|
// Under DCR the secret is provisioned at runtime (main.go), so like the
|
|
// clientID check above it is skipped when DCR is enabled (R135).
|
|
if (c.RequireTokenIntrospection || c.AllowOpaqueTokens) && c.ClientSecret == "" && !(dcrEnabled && c.ClientID == "") {
|
|
return fmt.Errorf("clientSecret is required when requireTokenIntrospection or allowOpaqueTokens is enabled (opaque-token introspection authenticates via client_secret_basic)")
|
|
}
|
|
|
|
// Validate session encryption key
|
|
if c.SessionEncryptionKey == "" {
|
|
return fmt.Errorf("sessionEncryptionKey is required")
|
|
}
|
|
if len(c.SessionEncryptionKey) < MinSessionEncryptionKeyLength {
|
|
return fmt.Errorf("sessionEncryptionKey must be at least %d characters long", MinSessionEncryptionKeyLength)
|
|
}
|
|
|
|
// Validate log level
|
|
if c.LogLevel != "" && !isValidLogLevel(c.LogLevel) {
|
|
return fmt.Errorf("logLevel must be one of: debug, info, error")
|
|
}
|
|
|
|
// Validate excluded URLs
|
|
for _, url := range c.ExcludedURLs {
|
|
if !strings.HasPrefix(url, "/") {
|
|
return fmt.Errorf("excluded URL must start with /: %s", url)
|
|
}
|
|
if strings.Contains(url, "..") {
|
|
return fmt.Errorf("excluded URL must not contain path traversal: %s", url)
|
|
}
|
|
if strings.Contains(url, "*") {
|
|
return fmt.Errorf("excluded URL must not contain wildcards: %s", url)
|
|
}
|
|
}
|
|
|
|
// Validate revocation URL if set
|
|
if c.RevocationURL != "" && !isValidSecureURL(c.RevocationURL) {
|
|
return fmt.Errorf("revocationURL must be a valid HTTPS URL")
|
|
}
|
|
|
|
// Validate end session URL if set
|
|
if c.OIDCEndSessionURL != "" && !isValidSecureURL(c.OIDCEndSessionURL) {
|
|
return fmt.Errorf("oidcEndSessionURL must be a valid HTTPS URL")
|
|
}
|
|
// Validate introspection URL if set
|
|
if c.IntrospectionURL != "" && !isValidSecureURL(c.IntrospectionURL) {
|
|
return fmt.Errorf("introspectionURL must be a valid HTTPS URL")
|
|
}
|
|
|
|
// Validate post-logout redirect URI if set
|
|
if c.PostLogoutRedirectURI != "" && c.PostLogoutRedirectURI != "/" {
|
|
if !isValidSecureURL(c.PostLogoutRedirectURI) && !strings.HasPrefix(c.PostLogoutRedirectURI, "/") {
|
|
return fmt.Errorf("postLogoutRedirectURI must be either a valid HTTPS URL or start with /")
|
|
}
|
|
}
|
|
|
|
// Validate rate limit
|
|
if c.RateLimit < MinRateLimit {
|
|
return fmt.Errorf("rateLimit must be at least %d", MinRateLimit)
|
|
}
|
|
|
|
// Validate refresh grace period
|
|
if c.RefreshGracePeriodSeconds < 0 {
|
|
return fmt.Errorf("refreshGracePeriodSeconds cannot be negative")
|
|
}
|
|
// Upper bound (mirroring the SessionMaxAge guard below): this seconds
|
|
// value is converted to time.Duration at startup
|
|
// (time.Duration(x) * time.Second). Values above ~9.22e9 seconds
|
|
// (~292 years) overflow int64 nanoseconds and wrap negative, pulling
|
|
// the refresh threshold into the past and forcing perpetual refresh /
|
|
// re-auth. Reject at startup rather than fail at runtime.
|
|
if int64(c.RefreshGracePeriodSeconds) > math.MaxInt64/int64(time.Second) {
|
|
return fmt.Errorf("refreshGracePeriodSeconds is too large; must be at most %d seconds", math.MaxInt64/int64(time.Second))
|
|
}
|
|
|
|
// Validate refresh-token max-age heuristic
|
|
if c.MaxRefreshTokenAgeSeconds < 0 {
|
|
return fmt.Errorf("maxRefreshTokenAgeSeconds cannot be negative")
|
|
}
|
|
// Upper bound (same overflow class): a wrapped-negative max-age makes
|
|
// every session with an issued_at look refresh-token-expired (mass
|
|
// re-auth). Reject at startup.
|
|
if int64(c.MaxRefreshTokenAgeSeconds) > math.MaxInt64/int64(time.Second) {
|
|
return fmt.Errorf("maxRefreshTokenAgeSeconds is too large; must be at most %d seconds", math.MaxInt64/int64(time.Second))
|
|
}
|
|
|
|
// Validate session max age. A negative value would make every session
|
|
// "older than maxAge" immediately (time-since-createdAt > negative),
|
|
// permanently locking users out.
|
|
if c.SessionMaxAge < 0 {
|
|
return fmt.Errorf("sessionMaxAge cannot be negative")
|
|
}
|
|
// Upper bound: sessionMaxAge is converted to a time.Duration as
|
|
// SessionMaxAge * time.Second (main.go). Values above ~9.22e9 seconds
|
|
// (~292 years) overflow int64 nanoseconds and wrap negative, which the
|
|
// growth comparison would then treat as "always expired": an instant
|
|
// mass lockout. Reject at startup rather than fail at runtime.
|
|
if int64(c.SessionMaxAge) > math.MaxInt64/int64(time.Second) {
|
|
return fmt.Errorf("sessionMaxAge is too large; must be at most %d seconds", math.MaxInt64/int64(time.Second))
|
|
}
|
|
|
|
// Validate audience if specified
|
|
if c.Audience != "" {
|
|
// Validate audience format - should be a valid identifier or URL
|
|
if len(c.Audience) > 256 {
|
|
return fmt.Errorf("audience must not exceed 256 characters")
|
|
}
|
|
|
|
// If audience looks like a URL, validate it's HTTPS
|
|
if strings.HasPrefix(c.Audience, "http://") {
|
|
return fmt.Errorf("audience URL must use HTTPS, not HTTP")
|
|
}
|
|
|
|
// Prevent wildcard audiences which could weaken security
|
|
if strings.Contains(c.Audience, "*") {
|
|
return fmt.Errorf("audience must not contain wildcards")
|
|
}
|
|
|
|
// Validate that audience doesn't contain obvious injection patterns
|
|
if strings.ContainsAny(c.Audience, "\n\r\t\x00") {
|
|
return fmt.Errorf("audience contains invalid characters")
|
|
}
|
|
}
|
|
|
|
// EnableBearerAuth requires an explicit Audience or Resource: it cannot default to
|
|
// clientID (that path accepts ID tokens, a spoofing vector for the
|
|
// bearer M2M path). Enforced here in Validate so a standalone call
|
|
// fails closed, matching the NewWithContext constructor check (R134).
|
|
if c.EnableBearerAuth && c.Audience == "" && c.Resource == "" {
|
|
return fmt.Errorf("EnableBearerAuth=true requires Audience or Resource to be set explicitly (cannot default to clientID — that path accepts ID tokens)")
|
|
}
|
|
|
|
// Validate the RFC 8707 resource indicator if specified
|
|
if c.Resource != "" {
|
|
resource := c.Resource
|
|
if len(resource) > 256 {
|
|
return fmt.Errorf("resource %q must not exceed 256 characters", resource)
|
|
}
|
|
if strings.Contains(resource, "*") {
|
|
return fmt.Errorf("resource %q must not contain wildcards", resource)
|
|
}
|
|
if strings.ContainsAny(resource, "\n\r\t\x00") {
|
|
return fmt.Errorf("resource %q contains invalid characters", resource)
|
|
}
|
|
|
|
// RFC 8707 §2: the resource parameter MUST NOT include a fragment.
|
|
if strings.Contains(resource, "#") {
|
|
return fmt.Errorf("resource %q must not contain a fragment", resource)
|
|
}
|
|
|
|
u, err := url.Parse(resource)
|
|
if err != nil || !u.IsAbs() {
|
|
return fmt.Errorf("resource %q must be an absolute URI", resource)
|
|
}
|
|
|
|
// Enforce HTTPS (loopback HTTP permitted) for http/https resources;
|
|
// other absolute schemes (e.g. urn:) are accepted as-is.
|
|
if u.Scheme == "http" || u.Scheme == "https" {
|
|
if !isValidSecureURL(resource) {
|
|
return fmt.Errorf("resource %q must be a valid HTTPS URL", resource)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate Redis configuration if provided
|
|
if c.Redis != nil {
|
|
// Apply env fallbacks + defaults BEFORE validation: an operator
|
|
// enabling Redis via REDIS_ENABLED (the documented pattern) has
|
|
// Enabled==false at config load, so without this the guards
|
|
// within RedisConfig.Validate (address required, no negative
|
|
// fields) would be skipped, and the backend would then be built
|
|
// with an empty address and silently degrade to memory-only at
|
|
// runtime (R123).
|
|
c.Redis.ApplyEnvFallbacks()
|
|
c.Redis.ApplyDefaults()
|
|
if c.Redis.Enabled {
|
|
if err := c.Redis.Validate(); err != nil {
|
|
return fmt.Errorf("redis configuration error: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate HSTS max-age when HSTS is enabled. RFC 6797 s.6.1: a
|
|
// max-age of 0 directs the UA to REMOVE the host's HSTS policy,
|
|
// silently disabling the operator's intended protection; negative is
|
|
// invalid. The header emitter (GetSecurityHeadersApplier) uses the
|
|
// configured value verbatim, so a non-positive value must be
|
|
// rejected here rather than emitted as max-age=0 (R123).
|
|
if c.SecurityHeaders != nil && c.SecurityHeaders.StrictTransportSecurity && c.SecurityHeaders.StrictTransportSecurityMaxAge <= 0 {
|
|
return fmt.Errorf("strictTransportSecurityMaxAge must be positive when strictTransportSecurity is enabled")
|
|
}
|
|
|
|
// Validate headers configuration for template security. Header templates may
|
|
// emit the built-in claims plus any operator-configured AllowedClaims.
|
|
allowedClaims := claimsWhitelist(c.AllowedClaims)
|
|
for _, header := range c.Headers {
|
|
if header.Name == "" {
|
|
return fmt.Errorf("header name cannot be empty")
|
|
}
|
|
// An HTTP field-name must be an RFC 7230 token; anything else
|
|
// (space, tab, ':', controls) would be written verbatim by Go's
|
|
// server into every response line, corrupting it (R148).
|
|
if strings.ContainsAny(header.Name, " \t:()<>@,;\\\"/[]?={}") || strings.ContainsAny(header.Name, "\r\n\x00") {
|
|
return fmt.Errorf("header name %q contains characters invalid in an HTTP field name", header.Name)
|
|
}
|
|
if header.Value == "" {
|
|
return fmt.Errorf("header value template cannot be empty")
|
|
}
|
|
hasOpen := strings.Contains(header.Value, "{{")
|
|
hasClose := strings.Contains(header.Value, "}}")
|
|
if hasOpen != hasClose {
|
|
return fmt.Errorf("header value '%s' has unbalanced template delimiters (need both {{ and }})", header.Value)
|
|
}
|
|
if !hasOpen {
|
|
// Static/literal header value with no template actions (e.g. a shared
|
|
// proxy secret or a constant marker). Allowed as-is — it worked before
|
|
// the constructor started calling Validate() (issue #149). Reject only
|
|
// header-injection characters; the rendered value is emitted verbatim.
|
|
if strings.ContainsAny(header.Value, "\r\n\x00") {
|
|
return fmt.Errorf("header value for '%s' contains invalid characters (CR/LF/NUL)", header.Name)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Provide more helpful guidance for common template errors BEFORE security validation
|
|
if strings.Contains(header.Value, "{{.claims") {
|
|
return fmt.Errorf("header template '%s' appears to use lowercase 'claims' - use '{{.Claims...' instead (case sensitive)", header.Value)
|
|
}
|
|
if strings.Contains(header.Value, "{{.accessToken") {
|
|
return fmt.Errorf("header template '%s' appears to use lowercase 'accessToken' - use '{{.AccessToken...' instead (case sensitive)", header.Value)
|
|
}
|
|
if strings.Contains(header.Value, "{{.idToken") {
|
|
return fmt.Errorf("header template '%s' appears to use lowercase 'idToken' - use '{{.IdToken...' instead (case sensitive)", header.Value)
|
|
}
|
|
if strings.Contains(header.Value, "{{.refreshToken") {
|
|
return fmt.Errorf("header template '%s' appears to use lowercase 'refreshToken' - use '{{.RefreshToken...' instead (case sensitive)", header.Value)
|
|
}
|
|
|
|
// Validate template syntax and security
|
|
if err := validateTemplateSecure(header.Value, allowedClaims); err != nil {
|
|
return fmt.Errorf("header template '%s' failed security validation: %w", header.Value, err)
|
|
}
|
|
}
|
|
|
|
// SecurityHeader CORS lists and custom header values are emitted
|
|
// verbatim into response headers (GetSecurityHeadersApplier); a CR/LF
|
|
// would split or corrupt the header (R148). Static header template
|
|
// values are already checked above; guard these string lists too.
|
|
checkNoCRLF := func(kind, val string) error {
|
|
if strings.ContainsAny(val, "\r\n\x00") {
|
|
return fmt.Errorf("%s %q contains CR/LF/NUL, which would split the response header", kind, val)
|
|
}
|
|
return nil
|
|
}
|
|
if c.SecurityHeaders != nil {
|
|
for _, v := range c.SecurityHeaders.CORSAllowedMethods {
|
|
if err := checkNoCRLF("CORSAllowedMethods entry", v); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, v := range c.SecurityHeaders.CORSAllowedHeaders {
|
|
if err := checkNoCRLF("CORSAllowedHeaders entry", v); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, v := range c.SecurityHeaders.CORSAllowedOrigins {
|
|
if err := checkNoCRLF("CORSAllowedOrigins entry", v); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for name, value := range c.SecurityHeaders.CustomHeaders {
|
|
if err := checkNoCRLF("CustomHeaders value", value); err != nil {
|
|
return err
|
|
}
|
|
if err := checkNoCRLF("CustomHeaders name", name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scopes flow verbatim into the OIDC authorize request (buildAuthURL).
|
|
// An empty scope element (e.g. a trailing comma in a Traefik label
|
|
// list) or whitespace-bearing scope is rejected by strict OIDC
|
|
// providers, and an override that drops the mandatory "openid" scope
|
|
// makes every login fail at runtime (OIDC Core 3.1.2.1). Reject at
|
|
// startup instead (R148).
|
|
seenOpenID := false
|
|
for i, sc := range c.Scopes {
|
|
s := strings.TrimSpace(sc)
|
|
if s == "" {
|
|
return fmt.Errorf("scope %d must not be empty", i)
|
|
}
|
|
if strings.ContainsAny(s, " \t\r\n") {
|
|
return fmt.Errorf("scope %d must not contain whitespace: %q", i, sc)
|
|
}
|
|
if s == "openid" {
|
|
seenOpenID = true
|
|
}
|
|
}
|
|
if c.OverrideScopes && !seenOpenID {
|
|
return fmt.Errorf("overrideScopes is set but the mandatory \"openid\" scope is missing; add it to scopes")
|
|
}
|
|
|
|
// cookieDomain flows verbatim into the session cookie's Domain option;
|
|
// an invalid value (a full URL, whitespace, or characters illegal in a
|
|
// cookie domain) makes every browser reject the Set-Cookie, surfacing
|
|
// only as a login loop at runtime. Reject at startup instead.
|
|
if c.CookieDomain != "" {
|
|
if u, err := url.Parse(c.CookieDomain); err == nil && (u.Scheme != "" || u.Host != "") {
|
|
return fmt.Errorf("cookieDomain must be a bare host name or domain (not a full URL): %q", c.CookieDomain)
|
|
}
|
|
if strings.ContainsAny(c.CookieDomain, " \t\r\n;,") {
|
|
return fmt.Errorf("cookieDomain contains characters invalid in a cookie domain: %q", c.CookieDomain)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// safeClaimsFields is the single source of truth for which claim fields a header
|
|
// template may emit. It gates both static validation (validateTemplateSecure)
|
|
// and the runtime `get` function (main.go funcMap), so a claim can never be
|
|
// forwarded via `get`/`index` that a direct {{.Claims.x}} reference would reject.
|
|
// Includes standard OIDC claims and common provider-specific claims.
|
|
var safeClaimsFields = map[string]bool{
|
|
// Standard OIDC claims
|
|
"email": true,
|
|
"name": true,
|
|
"given_name": true,
|
|
"family_name": true,
|
|
"preferred_username": true,
|
|
"sub": true,
|
|
"iss": true,
|
|
"aud": true,
|
|
"exp": true,
|
|
"iat": true,
|
|
"groups": true,
|
|
"roles": true,
|
|
// Common custom claims
|
|
"internal_role": true, // Custom roles field (issue #60)
|
|
"role": true, // Alternative role field
|
|
"department": true, // Organization info
|
|
"organization": true, // Organization info
|
|
// Provider-specific claims
|
|
"realm_access": true, // Keycloak specific
|
|
"resource_access": true, // Keycloak specific
|
|
"oid": true, // Azure AD object ID
|
|
"tid": true, // Azure AD tenant ID
|
|
"upn": true, // Azure AD User Principal Name
|
|
"hd": true, // Google hosted domain
|
|
"picture": true, // Profile picture
|
|
// Additional standard claims
|
|
"locale": true, // User locale
|
|
"zoneinfo": true, // Timezone
|
|
"phone_number": true, // Contact info
|
|
"email_verified": true, // Email verification status
|
|
"updated_at": true, // Last update time
|
|
}
|
|
|
|
// validateTemplateSecure validates template expressions for security vulnerabilities.
|
|
// It checks for dangerous template patterns that could lead to code execution or data leaks
|
|
// while allowing safe custom functions for field access and default values.
|
|
// validateTemplateSecure lives in template_validation.go (AST-based validation).
|
|
|
|
// isValidSecureURL checks if a given string represents a valid, absolute HTTPS URL.
|
|
// It uses url.Parse and checks for a nil error, an "https" scheme, and a non-empty host.
|
|
//
|
|
// Parameters:
|
|
// - s: The URL string to validate.
|
|
//
|
|
// Returns:
|
|
// - true if the string is a valid HTTPS URL, false otherwise.
|
|
//
|
|
// isValidSecureURL validates that a URL string is well-formed and uses HTTPS.
|
|
// Returns true if the URL is valid and secure (HTTPS), false otherwise.
|
|
func isValidSecureURL(s string) bool {
|
|
u, err := url.Parse(s)
|
|
if err != nil || u.Host == "" {
|
|
return false
|
|
}
|
|
if u.Scheme == "https" {
|
|
return true
|
|
}
|
|
// Permit plaintext HTTP only for loopback hosts (local development,
|
|
// in-cluster sidecar providers, tests). Loopback traffic never leaves the
|
|
// host, so it is not exposed to network MITM; remote providers must use
|
|
// HTTPS. Mirrors the RFC 8252 loopback allowance.
|
|
if u.Scheme == "http" && isLoopbackHost(u.Hostname()) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isLoopbackHost reports whether host is "localhost" or a loopback IP literal
|
|
// (127.0.0.0/8 or ::1).
|
|
func isLoopbackHost(host string) bool {
|
|
if strings.EqualFold(host, "localhost") {
|
|
return true
|
|
}
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
return ip.IsLoopback()
|
|
}
|
|
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:
|
|
// - level: The log level string to validate.
|
|
//
|
|
// Returns:
|
|
// - true if the log level is valid, false otherwise.
|
|
//
|
|
// isValidLogLevel checks if the provided log level is supported.
|
|
// Valid log levels are: debug, info, error.
|
|
func isValidLogLevel(level string) bool {
|
|
return level == "debug" || level == "info" || level == "error"
|
|
}
|
|
|
|
// Logger provides structured logging capabilities with different severity levels.
|
|
// It supports error, info, and debug levels with appropriate output streams
|
|
// and formatting for each level.
|
|
type Logger struct {
|
|
// logError handles error-level messages, writing to stderr
|
|
logError *log.Logger
|
|
// logInfo handles informational messages, writing to stdout
|
|
logInfo *log.Logger
|
|
// logDebug handles debug-level messages, writing to stdout when debug is enabled
|
|
logDebug *log.Logger
|
|
}
|
|
|
|
// NewLogger creates and configures a new Logger instance based on the provided log level.
|
|
// It initializes loggers for ERROR (stderr), INFO (stdout), and DEBUG (stdout) levels,
|
|
// enabling output based on the specified level:
|
|
// - "error": Only ERROR messages are output.
|
|
// - "info": INFO and ERROR messages are output.
|
|
// - "debug": DEBUG, INFO, and ERROR messages are output.
|
|
//
|
|
// If an invalid level is provided, it defaults to behavior similar to "error".
|
|
//
|
|
// Parameters:
|
|
// - logLevel: The desired logging level ("debug", "info", or "error").
|
|
//
|
|
// Returns:
|
|
// - A pointer to the configured Logger instance.
|
|
//
|
|
// NewLogger creates a new logger instance with the specified log level.
|
|
// If logLevel is empty, defaults to "info". Invalid log levels default to "info".
|
|
func NewLogger(logLevel string) *Logger {
|
|
logError := log.New(io.Discard, "ERROR: TraefikOidcPlugin: ", log.Ldate|log.Ltime)
|
|
logInfo := log.New(io.Discard, "INFO: TraefikOidcPlugin: ", log.Ldate|log.Ltime)
|
|
logDebug := log.New(io.Discard, "DEBUG: TraefikOidcPlugin: ", log.Ldate|log.Ltime)
|
|
|
|
logError.SetOutput(os.Stderr)
|
|
|
|
if logLevel == "debug" || logLevel == "info" {
|
|
logInfo.SetOutput(os.Stdout)
|
|
}
|
|
if logLevel == "debug" {
|
|
logDebug.SetOutput(os.Stdout)
|
|
}
|
|
|
|
return &Logger{
|
|
logError: logError,
|
|
logInfo: logInfo,
|
|
logDebug: logDebug,
|
|
}
|
|
}
|
|
|
|
// Info logs a message at the INFO level using Printf style formatting.
|
|
// Output is directed to stdout if the configured log level is "info" or "debug".
|
|
//
|
|
// Parameters:
|
|
// - format: The format string (as in fmt.Printf).
|
|
// - args: The arguments for the format string.
|
|
//
|
|
// Info logs an informational message if the logger's level allows it.
|
|
func (l *Logger) Info(format string, args ...interface{}) {
|
|
l.logInfo.Printf(format, args...)
|
|
}
|
|
|
|
// Debug logs a message at the DEBUG level.
|
|
// Output is directed to stdout only if the configured log level is "debug".
|
|
//
|
|
// Parameters:
|
|
// - format: The format string (as in fmt.Printf).
|
|
// - args: The arguments for the format string.
|
|
//
|
|
// Debug logs a debug message if the logger's level allows it.
|
|
func (l *Logger) Debug(format string, args ...interface{}) {
|
|
l.logDebug.Printf(format, args...)
|
|
}
|
|
|
|
// Error logs a message at the ERROR level using Printf style formatting.
|
|
// Output is always directed to stderr, regardless of the configured log level.
|
|
//
|
|
// Parameters:
|
|
// - format: The format string (as in fmt.Printf).
|
|
// - args: The arguments for the format string.
|
|
//
|
|
// Error logs an error message. Errors are always logged regardless of level.
|
|
func (l *Logger) Error(format string, args ...interface{}) {
|
|
l.logError.Printf(format, args...)
|
|
}
|
|
|
|
// Infof logs a message at the INFO level using Printf style formatting.
|
|
// Equivalent to calling l.Info(format, args...).
|
|
// Output is directed to stdout if the configured log level is "info" or "debug".
|
|
//
|
|
// Parameters:
|
|
// - format: The format string (as in fmt.Printf).
|
|
// - args: The arguments for the format string.
|
|
//
|
|
// Infof logs a formatted informational message if the logger's level allows it.
|
|
func (l *Logger) Infof(format string, args ...interface{}) {
|
|
l.logInfo.Printf(format, args...)
|
|
}
|
|
|
|
// Debugf logs a formatted message at the DEBUG level.
|
|
// Equivalent to calling l.Debug(format, args...).
|
|
// Output is directed to stdout only if the configured log level is "debug".
|
|
//
|
|
// Parameters:
|
|
// - format: The format string (as in fmt.Printf).
|
|
// - args: The arguments for the format string.
|
|
//
|
|
// Debugf logs a formatted debug message if the logger's level allows it.
|
|
func (l *Logger) Debugf(format string, args ...interface{}) {
|
|
l.logDebug.Printf(format, args...)
|
|
}
|
|
|
|
// Errorf logs a message at the ERROR level using Printf style formatting.
|
|
// Equivalent to calling l.Error(format, args...).
|
|
// Output is always directed to stderr, regardless of the configured log level.
|
|
//
|
|
// Parameters:
|
|
// - format: The format string (as in fmt.Printf).
|
|
// - args: The arguments for the format string.
|
|
//
|
|
// Errorf logs a formatted error message. Errors are always logged regardless of level.
|
|
func (l *Logger) Errorf(format string, args ...interface{}) {
|
|
l.logError.Printf(format, args...)
|
|
}
|
|
|
|
// IsDebug reports whether debug-level logging is enabled.
|
|
// Callers should use this to avoid expensive format-string expansion
|
|
// (e.g. on hot paths under yaegi) when debug output would be discarded.
|
|
func (l *Logger) IsDebug() bool {
|
|
if l == nil || l.logDebug == nil {
|
|
return false
|
|
}
|
|
return l.logDebug.Writer() != io.Discard
|
|
}
|
|
|
|
// newNoOpLogger creates a logger that discards all output.
|
|
//
|
|
// Deprecated: Use GetSingletonNoOpLogger() instead for better memory efficiency.
|
|
func newNoOpLogger() *Logger {
|
|
return GetSingletonNoOpLogger()
|
|
}
|
|
|
|
// handleError logs an error message using the provided logger and sends an HTTP error
|
|
// response to the client with the specified message and status code.
|
|
//
|
|
// Parameters:
|
|
// - w: The http.ResponseWriter to send the error response to.
|
|
// - message: The error message string.
|
|
// - code: The HTTP status code for the response.
|
|
// - logger: The Logger instance to use for logging the error.
|
|
//
|
|
// GetSecurityHeadersApplier returns a function that applies security headers
|
|
func (c *Config) GetSecurityHeadersApplier() func(http.ResponseWriter, *http.Request) {
|
|
if c.SecurityHeaders == nil || !c.SecurityHeaders.Enabled {
|
|
return nil
|
|
}
|
|
|
|
return func(rw http.ResponseWriter, req *http.Request) {
|
|
headers := rw.Header()
|
|
|
|
// Apply basic security headers based on configuration
|
|
if c.SecurityHeaders.FrameOptions != "" {
|
|
headers.Set("X-Frame-Options", c.SecurityHeaders.FrameOptions)
|
|
}
|
|
if c.SecurityHeaders.ContentTypeOptions != "" {
|
|
headers.Set("X-Content-Type-Options", c.SecurityHeaders.ContentTypeOptions)
|
|
}
|
|
if c.SecurityHeaders.XSSProtection != "" {
|
|
headers.Set("X-XSS-Protection", c.SecurityHeaders.XSSProtection)
|
|
}
|
|
if c.SecurityHeaders.ReferrerPolicy != "" {
|
|
headers.Set("Referrer-Policy", c.SecurityHeaders.ReferrerPolicy)
|
|
}
|
|
if c.SecurityHeaders.ContentSecurityPolicy != "" {
|
|
headers.Set("Content-Security-Policy", c.SecurityHeaders.ContentSecurityPolicy)
|
|
}
|
|
if c.SecurityHeaders.PermissionsPolicy != "" {
|
|
headers.Set("Permissions-Policy", c.SecurityHeaders.PermissionsPolicy)
|
|
}
|
|
if c.SecurityHeaders.CrossOriginResourcePolicy != "" {
|
|
headers.Set("Cross-Origin-Resource-Policy", c.SecurityHeaders.CrossOriginResourcePolicy)
|
|
}
|
|
if c.SecurityHeaders.CrossOriginOpenerPolicy != "" {
|
|
headers.Set("Cross-Origin-Opener-Policy", c.SecurityHeaders.CrossOriginOpenerPolicy)
|
|
}
|
|
if c.SecurityHeaders.CrossOriginEmbedderPolicy != "" {
|
|
headers.Set("Cross-Origin-Embedder-Policy", c.SecurityHeaders.CrossOriginEmbedderPolicy)
|
|
}
|
|
|
|
// HSTS for HTTPS
|
|
if (req.TLS != nil || req.Header.Get("X-Forwarded-Proto") == "https") && c.SecurityHeaders.StrictTransportSecurity {
|
|
hstsValue := fmt.Sprintf("max-age=%d", c.SecurityHeaders.StrictTransportSecurityMaxAge)
|
|
if c.SecurityHeaders.StrictTransportSecuritySubdomains {
|
|
hstsValue += "; includeSubDomains"
|
|
}
|
|
if c.SecurityHeaders.StrictTransportSecurityPreload {
|
|
hstsValue += "; preload"
|
|
}
|
|
headers.Set("Strict-Transport-Security", hstsValue)
|
|
}
|
|
|
|
// CORS headers
|
|
if c.SecurityHeaders.CORSEnabled {
|
|
origin := req.Header.Get("Origin")
|
|
if origin != "" && isOriginAllowed(origin, c.SecurityHeaders.CORSAllowedOrigins) {
|
|
headers.Set("Access-Control-Allow-Origin", origin)
|
|
}
|
|
|
|
if len(c.SecurityHeaders.CORSAllowedMethods) > 0 {
|
|
headers.Set("Access-Control-Allow-Methods", strings.Join(c.SecurityHeaders.CORSAllowedMethods, ", "))
|
|
}
|
|
if len(c.SecurityHeaders.CORSAllowedHeaders) > 0 {
|
|
headers.Set("Access-Control-Allow-Headers", strings.Join(c.SecurityHeaders.CORSAllowedHeaders, ", "))
|
|
}
|
|
if c.SecurityHeaders.CORSAllowCredentials {
|
|
headers.Set("Access-Control-Allow-Credentials", "true")
|
|
}
|
|
if c.SecurityHeaders.CORSMaxAge > 0 {
|
|
headers.Set("Access-Control-Max-Age", strconv.Itoa(c.SecurityHeaders.CORSMaxAge))
|
|
}
|
|
}
|
|
|
|
// Custom headers
|
|
for name, value := range c.SecurityHeaders.CustomHeaders {
|
|
headers.Set(name, value)
|
|
}
|
|
|
|
// Remove server headers
|
|
if c.SecurityHeaders.DisableServerHeader {
|
|
headers.Del("Server")
|
|
}
|
|
if c.SecurityHeaders.DisablePoweredByHeader {
|
|
headers.Del("X-Powered-By")
|
|
}
|
|
}
|
|
}
|
|
|
|
// isOriginAllowed checks if an origin is in the allowed list
|
|
// Validate checks if the Redis configuration is valid
|
|
func (rc *RedisConfig) Validate() error {
|
|
if !rc.Enabled {
|
|
return nil
|
|
}
|
|
|
|
if rc.Address == "" {
|
|
return fmt.Errorf("redis address is required when Redis is enabled")
|
|
}
|
|
|
|
// Validate cache mode
|
|
if rc.CacheMode != "" {
|
|
validModes := map[string]bool{
|
|
"redis": true,
|
|
"hybrid": true,
|
|
"memory": true,
|
|
}
|
|
if !validModes[rc.CacheMode] {
|
|
return fmt.Errorf("invalid cache mode: %s (must be 'redis', 'hybrid', or 'memory')", rc.CacheMode)
|
|
}
|
|
}
|
|
|
|
// Validate connection settings
|
|
if rc.PoolSize < 0 {
|
|
return fmt.Errorf("pool size cannot be negative")
|
|
}
|
|
if rc.ConnectTimeout < 0 {
|
|
return fmt.Errorf("connect timeout cannot be negative")
|
|
}
|
|
if rc.ReadTimeout < 0 {
|
|
return fmt.Errorf("read timeout cannot be negative")
|
|
}
|
|
if rc.WriteTimeout < 0 {
|
|
return fmt.Errorf("write timeout cannot be negative")
|
|
}
|
|
|
|
// Validate hybrid mode settings
|
|
if rc.CacheMode == "hybrid" {
|
|
if rc.HybridL1Size < 0 {
|
|
return fmt.Errorf("hybrid L1 size cannot be negative")
|
|
}
|
|
if rc.HybridL1MemoryMB < 0 {
|
|
return fmt.Errorf("hybrid L1 memory cannot be negative")
|
|
}
|
|
}
|
|
|
|
// Validate circuit breaker settings
|
|
if rc.CircuitBreakerThreshold < 0 {
|
|
return fmt.Errorf("circuit breaker threshold cannot be negative")
|
|
}
|
|
if rc.CircuitBreakerTimeout < 0 {
|
|
return fmt.Errorf("circuit breaker timeout cannot be negative")
|
|
}
|
|
|
|
// Validate health check settings
|
|
if rc.HealthCheckInterval < 0 {
|
|
return fmt.Errorf("health check interval cannot be negative")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ApplyDefaults sets default values for Redis configuration when fields are not explicitly set.
|
|
// This ensures reasonable defaults while allowing full customization through configuration.
|
|
func (rc *RedisConfig) ApplyDefaults() {
|
|
// Only apply defaults if Redis is enabled
|
|
if !rc.Enabled {
|
|
return
|
|
}
|
|
|
|
// Connection defaults
|
|
if rc.KeyPrefix == "" {
|
|
rc.KeyPrefix = "traefikoidc:"
|
|
}
|
|
if rc.PoolSize == 0 {
|
|
rc.PoolSize = 10
|
|
}
|
|
if rc.ConnectTimeout == 0 {
|
|
rc.ConnectTimeout = 5
|
|
}
|
|
if rc.ReadTimeout == 0 {
|
|
rc.ReadTimeout = 3
|
|
}
|
|
if rc.WriteTimeout == 0 {
|
|
rc.WriteTimeout = 3
|
|
}
|
|
|
|
// Cache mode defaults
|
|
if rc.CacheMode == "" {
|
|
rc.CacheMode = "redis" // Default to redis-only mode for simplicity
|
|
}
|
|
|
|
// Hybrid mode specific defaults
|
|
if rc.CacheMode == "hybrid" {
|
|
if rc.HybridL1Size == 0 {
|
|
rc.HybridL1Size = 500
|
|
}
|
|
if rc.HybridL1MemoryMB == 0 {
|
|
rc.HybridL1MemoryMB = 10
|
|
}
|
|
}
|
|
|
|
// Resilience features - these use a different pattern to detect if they were explicitly set
|
|
// Since bool fields default to false, we need to be careful about defaults
|
|
// For now, we'll enable by default only if not explicitly disabled via environment
|
|
if rc.CircuitBreakerThreshold == 0 {
|
|
rc.CircuitBreakerThreshold = 5
|
|
}
|
|
if rc.CircuitBreakerTimeout == 0 {
|
|
rc.CircuitBreakerTimeout = 60
|
|
}
|
|
if rc.HealthCheckInterval == 0 {
|
|
rc.HealthCheckInterval = 30
|
|
}
|
|
}
|
|
|
|
// ApplyEnvFallbacks applies environment variable values as fallbacks for empty config fields.
|
|
// This allows environment variables to be used as optional overrides only when the
|
|
// corresponding config field is not set through Traefik's dynamic configuration.
|
|
// The plugin configuration takes precedence over environment variables.
|
|
func (rc *RedisConfig) ApplyEnvFallbacks() {
|
|
// Only apply env fallbacks if Redis is not already configured
|
|
if !rc.Enabled {
|
|
// Check if Redis should be enabled from environment
|
|
enabledStr := os.Getenv("REDIS_ENABLED")
|
|
if enabledStr == "true" || enabledStr == "1" {
|
|
rc.Enabled = true
|
|
}
|
|
}
|
|
|
|
// Only apply other env vars if Redis is enabled
|
|
if !rc.Enabled {
|
|
return
|
|
}
|
|
|
|
// Apply environment variables only for empty fields
|
|
if rc.Address == "" {
|
|
if addr := os.Getenv("REDIS_ADDRESS"); addr != "" {
|
|
rc.Address = addr
|
|
}
|
|
}
|
|
|
|
if rc.Password == "" {
|
|
rc.Password = os.Getenv("REDIS_PASSWORD")
|
|
}
|
|
|
|
if rc.KeyPrefix == "" {
|
|
if prefix := os.Getenv("REDIS_KEY_PREFIX"); prefix != "" {
|
|
rc.KeyPrefix = prefix
|
|
}
|
|
}
|
|
|
|
if rc.CacheMode == "" {
|
|
if mode := os.Getenv("REDIS_CACHE_MODE"); mode != "" {
|
|
rc.CacheMode = mode
|
|
}
|
|
}
|
|
|
|
// Apply numeric values only if not already set
|
|
if rc.DB == 0 {
|
|
if dbStr := os.Getenv("REDIS_DB"); dbStr != "" {
|
|
if db, err := strconv.Atoi(dbStr); err == nil && db > 0 {
|
|
rc.DB = db
|
|
}
|
|
}
|
|
}
|
|
|
|
if rc.PoolSize == 0 {
|
|
if poolSizeStr := os.Getenv("REDIS_POOL_SIZE"); poolSizeStr != "" {
|
|
if poolSize, err := strconv.Atoi(poolSizeStr); err == nil && poolSize > 0 {
|
|
rc.PoolSize = poolSize
|
|
}
|
|
}
|
|
}
|
|
|
|
if rc.ConnectTimeout == 0 {
|
|
if timeoutStr := os.Getenv("REDIS_CONNECT_TIMEOUT"); timeoutStr != "" {
|
|
if timeout, err := strconv.Atoi(timeoutStr); err == nil && timeout > 0 {
|
|
rc.ConnectTimeout = timeout
|
|
}
|
|
}
|
|
}
|
|
|
|
if rc.ReadTimeout == 0 {
|
|
if timeoutStr := os.Getenv("REDIS_READ_TIMEOUT"); timeoutStr != "" {
|
|
if timeout, err := strconv.Atoi(timeoutStr); err == nil && timeout > 0 {
|
|
rc.ReadTimeout = timeout
|
|
}
|
|
}
|
|
}
|
|
|
|
if rc.WriteTimeout == 0 {
|
|
if timeoutStr := os.Getenv("REDIS_WRITE_TIMEOUT"); timeoutStr != "" {
|
|
if timeout, err := strconv.Atoi(timeoutStr); err == nil && timeout > 0 {
|
|
rc.WriteTimeout = timeout
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply boolean values from env only if not already set in config
|
|
if !rc.EnableTLS {
|
|
if tlsStr := os.Getenv("REDIS_ENABLE_TLS"); tlsStr == "true" || tlsStr == "1" {
|
|
rc.EnableTLS = true
|
|
}
|
|
}
|
|
|
|
if !rc.TLSSkipVerify {
|
|
if skipStr := os.Getenv("REDIS_TLS_SKIP_VERIFY"); skipStr == "true" || skipStr == "1" {
|
|
rc.TLSSkipVerify = true
|
|
}
|
|
}
|
|
|
|
// Hybrid mode settings
|
|
if rc.HybridL1Size == 0 {
|
|
if sizeStr := os.Getenv("REDIS_HYBRID_L1_SIZE"); sizeStr != "" {
|
|
if size, err := strconv.Atoi(sizeStr); err == nil && size > 0 {
|
|
rc.HybridL1Size = size
|
|
}
|
|
}
|
|
}
|
|
|
|
if rc.HybridL1MemoryMB == 0 {
|
|
if memStr := os.Getenv("REDIS_HYBRID_L1_MEMORY_MB"); memStr != "" {
|
|
if mem, err := strconv.ParseInt(memStr, 10, 64); err == nil && mem > 0 {
|
|
rc.HybridL1MemoryMB = mem
|
|
}
|
|
}
|
|
}
|
|
|
|
// Resilience fallbacks documented in examples/redis-config.yaml. These were
|
|
// previously absent: an operator following the documented env var names got
|
|
// no effect. Match the bool/numeric patterns used above.
|
|
if !rc.EnableCircuitBreaker {
|
|
if env := os.Getenv("REDIS_ENABLE_CIRCUIT_BREAKER"); env == "true" || env == "1" {
|
|
rc.EnableCircuitBreaker = true
|
|
}
|
|
}
|
|
if rc.CircuitBreakerThreshold == 0 {
|
|
if s := os.Getenv("REDIS_CIRCUIT_BREAKER_THRESHOLD"); s != "" {
|
|
if v, err := strconv.Atoi(s); err == nil && v > 0 {
|
|
rc.CircuitBreakerThreshold = v
|
|
}
|
|
}
|
|
}
|
|
if rc.CircuitBreakerTimeout == 0 {
|
|
if s := os.Getenv("REDIS_CIRCUIT_BREAKER_TIMEOUT"); s != "" {
|
|
if v, err := strconv.Atoi(s); err == nil && v > 0 {
|
|
rc.CircuitBreakerTimeout = v
|
|
}
|
|
}
|
|
}
|
|
if !rc.EnableHealthCheck {
|
|
if env := os.Getenv("REDIS_ENABLE_HEALTH_CHECK"); env == "true" || env == "1" {
|
|
rc.EnableHealthCheck = true
|
|
}
|
|
}
|
|
if rc.HealthCheckInterval == 0 {
|
|
if s := os.Getenv("REDIS_HEALTH_CHECK_INTERVAL"); s != "" {
|
|
if v, err := strconv.Atoi(s); err == nil && v > 0 {
|
|
rc.HealthCheckInterval = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func isOriginAllowed(origin string, allowedOrigins []string) bool {
|
|
for _, allowed := range allowedOrigins {
|
|
if origin == allowed || allowed == "*" {
|
|
return true
|
|
}
|
|
// Simple wildcard matching for subdomains
|
|
if strings.Contains(allowed, "*") {
|
|
if strings.HasPrefix(allowed, "https://*.") {
|
|
domain := strings.TrimPrefix(allowed, "https://*.")
|
|
// The scheme part of the wildcard must be honored: an https
|
|
// wildcard must not admit an http sibling (which, with
|
|
// AllowCredentials, would be reflected and credentialed)
|
|
// (R136).
|
|
if (strings.HasPrefix(origin, "https://") && strings.HasSuffix(origin, "."+domain)) || origin == "https://"+domain {
|
|
return true
|
|
}
|
|
}
|
|
if strings.HasPrefix(allowed, "http://*.") {
|
|
domain := strings.TrimPrefix(allowed, "http://*.")
|
|
if (strings.HasPrefix(origin, "http://") && strings.HasSuffix(origin, "."+domain)) || origin == "http://"+domain {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|