mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-12 04:04:26 +00:00
* fix(settings): allow documented range + static header templates (#149)
Upgrading past the release where NewWithContext began calling
config.Validate() (546ceb9) broke `headers` configs that worked in 0.8.25:
New() returned a nil handler and Traefik logged the opaque "invalid handler
type: <nil>". Two header-validation checks were stricter than the documented,
legitimate usage:
- validateTemplateSecure rejected the documented multi-valued-claim join
"{{range $i,$e := .Claims.groups}}...{{end}}" as a dangerous {{range}},
even though README/CONFIGURATION show exactly that for groups/roles.
- Config.Validate required every header value to contain {{ }}, rejecting
static/literal values such as a shared proxy secret.
Fixes:
- Allow static/literal header values (reject only CR/LF/NUL header-injection
chars); flag unbalanced {{ }} explicitly.
- Allow {{range}} only when its clause targets .Claims.<field>; a range over
the bare .Claims map stays blocked and a decoy {{.Claims.x}} elsewhere no
longer launders it. Generalize the claims whitelist scan to .Claims. so a
claim reached via range/with/default is whitelisted identically to a direct
{{.Claims.x}} reference.
- normalizeTemplateDelimiters now collapses ALL whitespace inside {{ }}
(newline/CR/FF too), closing a newline-obfuscation bypass ({{\nrange,
{{\nindex) found in review.
- Log the concrete validation reason at construction so operators see WHY the
middleware failed to build instead of only "invalid handler type: <nil>".
Adds issue149_regression_test.go: pins the exact issue config, the documented
groups template rendering "admin,users" past the runtime sanitizer, static and
delimiter edge cases, and the security boundary (whole-Claims-map dump via
range, non-whitelisted claims, call/define/template/printf, and
newline-obfuscated actions all rejected).
* fix(settings): AST-based header template validation, close whitelist bypasses
Follow-up hardening to the #149 work. An adversarial review showed the
substring-based validateTemplateSecure could not actually bound what a header
template reads: get/index with arbitrary keys, {{with .Claims}} field access,
and a whitelisted-decoy + bare {{.}}/{{$}}/{{.Claims}} render all leaked the
whole root (every raw token) or the whole Claims map past validation. The core
problem is structural — the meaning of "." depends on scope, which substrings
cannot see.
Replace the substring validator with an AST walk (text/template/parse):
- Parse with only get/default known; every other function (printf, call, index,
js, urlquery, ...) then fails to parse and is rejected — no blocklist.
- Reject {{define}}/{{block}} (they produce extra trees).
- Walk the tree tracking whether "." is the root. Permit ONLY: literal text;
the four root token fields (AccessToken, IdToken, IDToken, RefreshToken);
whitelisted .Claims.<field>; range/with over a whitelisted .Claims.<field>
("." inside the body is the already-whitelisted element); get on .Claims with
a whitelisted literal key; default over safe values; {{if}} control flow and
$-scoped range/with/decl variables. Everything else — bare {{.}} / {{$}}
(whole-root dump), bare {{.Claims}} (whole-map dump), non-whitelisted claims,
$.Claims root reach — is rejected.
Also closes the get/default exfil and the with-over-bare-map vectors, and fixes
ID-token forwarding: templateData now carries both IDToken and IdToken and the
validator accepts {{.IdToken}} (documented) and {{.IDToken}}; previously the
documented spelling rendered empty and the working one was rejected.
Runtime `get` additionally enforces the claims whitelist on its key, and
safeClaimsFields is promoted to a package-level single source of truth shared by
the validator and the get helper. The header template funcMap is extracted to
headerTemplateFuncMap() so tests exercise the exact production map. Removes the
now-dead normalizeTemplateDelimiters/regex (the parser handles whitespace).
Extends issue149_regression_test.go: renders through the real funcMap to prove
get cannot leak; asserts the whole-root/whole-map/decoy/var-launder/index
vectors are rejected while every documented pattern (groups/roles join,
with .Claims.email, get .Claims "internal_role", default, both IdToken
spellings) still validates and renders.
* feat(settings): allowedClaims to extend the header-template claim whitelist
The AST validator and the runtime `get` only permit a fixed set of claim fields
in header value templates. Providers issue custom claims (e.g. employee_id) that
aren't built in, and there was no supported way to forward them — the previous
`get` escape hatch was closed by the security hardening.
Add `allowedClaims []string` to Config. Listed names extend the effective
whitelist for BOTH static validation and the runtime `get` (a single
claimsWhitelist() = built-in safeClaimsFields + AllowedClaims, so the two never
drift). Built-in claims need not be listed; unlisted claims stay rejected.
allowedClaims:
- employee_id
headers:
- name: X-Employee-Id
value: "{{`{{.Claims.employee_id}}`}}"
Implementation: the whitelist is threaded through the validator (now a small
templateValidator struct carrying the allowed set) and headerTemplateFuncMap
instead of the package global, so it is per-config with no shared mutable state.
Docs: README and docs/CONFIGURATION document the whitelist, the validation
behaviour, and allowedClaims (grounded in safeClaimsFields).
Tests: TestIssue149_AllowedClaimsExtendsWhitelist pins that a custom claim is
rejected by default and validates + renders once listed, via both direct access
and get, while an unlisted claim stays rejected. A regression assertion that
coupled to the old validator's "dangerous" wording now checks the stable
"failed security validation" wrapper (the templates are still rejected).
1167 lines
47 KiB
Go
1167 lines
47 KiB
Go
package traefikoidc
|
|
|
|
import (
|
|
"crypto/x509"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// 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"`
|
|
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"`
|
|
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"`
|
|
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"`
|
|
MinimalHeaders bool `json:"minimalHeaders,omitempty"`
|
|
StripAuthCookies bool `json:"stripAuthCookies,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.
|
|
func (c *Config) Validate() error {
|
|
// 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 /")
|
|
}
|
|
|
|
// Validate client credentials
|
|
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)
|
|
}
|
|
|
|
// 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 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")
|
|
}
|
|
|
|
// Validate refresh-token max-age heuristic
|
|
if c.MaxRefreshTokenAgeSeconds < 0 {
|
|
return fmt.Errorf("maxRefreshTokenAgeSeconds cannot be negative")
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// Validate Redis configuration if provided
|
|
if c.Redis != nil && c.Redis.Enabled {
|
|
if err := c.Redis.Validate(); err != nil {
|
|
return fmt.Errorf("redis configuration error: %w", err)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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://*.")
|
|
if strings.HasSuffix(origin, "."+domain) || origin == "https://"+domain {
|
|
return true
|
|
}
|
|
}
|
|
if strings.HasPrefix(allowed, "http://*.") {
|
|
domain := strings.TrimPrefix(allowed, "http://*.")
|
|
if strings.HasSuffix(origin, "."+domain) || origin == "http://"+domain {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|