mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
546ceb949c
* fix(security): encrypt session cookies + fail closed on invalid config
Batch 1 of security audit remediation (ranks 1, 2, 6).
- session.go: derive independent HMAC + AES-256 keys via stdlib HKDF-SHA256
and build the gorilla cookie store with both, so session cookies are now
encrypted, not merely signed. The single-key store previously left OIDC
access/refresh/ID tokens recoverable from raw cookie bytes. Cookie format
changes, so existing sessions are invalidated on deploy (one-time re-login).
- main.go: call config.Validate() at construction and error out on failure,
instead of silently substituting a public hardcoded encryption key for
empty/short keys (which allowed session forgery). The yaegi analyzer
passes via .traefik.yml testData.
- settings.go: isValidSecureURL permits plaintext HTTP for loopback hosts
only (RFC 8252); remote providers must still use HTTPS.
- tests: complete configs that did not satisfy Validate(); add regression
tests in security_audit_fixes_test.go.
Configs below documented minimums (rateLimit < 10, key < 32 chars) are now
rejected at startup (fail closed).
* fix(security): validate discovered OIDC endpoints + pin introspection host
Batch 2 of security audit remediation (ranks 3, 4).
- url_helpers.go: add validateDiscoveredEndpoint, an SSRF screen for endpoints
taken from the provider discovery document (jwks_uri, token, authorization,
revocation, end_session, introspection, registration). Blocks link-local
(cloud metadata 169.254.169.254), multicast, unspecified and private
addresses (unless allowPrivateIPAddresses); blocks loopback unless the
configured providerURL is itself loopback (dev/test). Cross-domain JWKS
hosts (e.g. Google) stay allowed. Add sameHost helper.
- main.go: updateMetadataEndpoints screens every discovered endpoint and
blanks any that fail (fail closed downstream). The introspection endpoint
carries the client secret via HTTP Basic, so it is additionally pinned to
the providerURL host to stop a poisoned discovery document exfiltrating the
secret to an attacker-controlled host.
- tests: regression tests for the SSRF guard and the host pin.
* fix(security): close open redirects + anchor excluded-URL matching
Batch 3 of security audit remediation (ranks 5, 14, 15).
- auth_flow.go: run the stored incoming path through normalizeLogoutPath
before using it as the post-login redirect, so //evil.com and /\evil.com
payloads become host-relative (open-redirect, rank 5).
- url_helpers.go: excluded-URL matching is anchored at a natural boundary
(exact, sub-path "/", or file extension "."), so excluding "/public" no
longer also bypasses auth on "/publicsecret"; "/favicon" still matches
"/favicon.ico" (rank 14).
- internal/utils: X-Forwarded-Host is sanitized (first value only; reject
CRLF/whitespace/multi-value) before building redirect URLs (rank 15).
- helpers.go: the logout redirect used when there is no provider end-session
endpoint is host-relative, never an absolute URL derived from the
client-controllable request host (logout open-redirect, rank 15).
- tests: update two logout cases that asserted the old absolute redirect;
add regression tests.
* fix(security): reject unverified Azure tokens; fix transport TLS reuse
Batch 4 of security audit remediation (ranks 7, 11).
- token_validation_rs.go: an Azure nonce-bearing access token that cannot be
cryptographically verified no longer returns "authenticated" when there is
no ID token to corroborate it; it refreshes (if possible) or forces
re-authentication instead of failing open (rank 7).
- http_client_pool.go: the at-limit transport-reuse path now takes the write
lock before mutating refCount (fixes a data race) and only reuses a
transport whose TLS settings (CA pool + InsecureSkipVerify) match the
caller's, never one with a different trust store; if none matches it returns
nil so the caller falls back to a verifying default transport (rank 11).
- tests: add a transport-pool TLS-isolation regression test.
* fix(security): stop logging templated header values (token leak)
Batch 5 of security audit remediation (rank 16).
middleware.go: templated downstream headers commonly carry the access token
(e.g. "Authorization: Bearer {{.AccessToken}}"). The debug log line printed
the full header value, leaking credentials into logs. Log the header name and
byte length instead.
* fix(security): cache-key collision, cache-config divergence, fleet cleanup
Batch 6 of security audit remediation (ranks 9, 10, 12).
- token_manager.go: detectTokenType keys its cache on a SHA-256 hash of the
full token instead of the first 32 chars (which are only the base64url JWT
header). Distinct tokens sharing alg+kid no longer collide and get
mis-classified (rank 10).
- cache_manager.go: the process-global cache manager is initialized once and
shared across plugin instances; it now logs a loud warning when a later
instance requests a different explicit Redis backend that is silently
ignored, surfacing the cross-instance state-isolation hazard (rank 9).
- singleton_resources.go / main.go / utilities.go: track a process-global live
instance count; the shared singleton-token-cleanup task is stopped only when
the LAST instance shuts down, so one instance's Close() (e.g. a config reload)
no longer kills cleanup for surviving instances (rank 12).
- tests: update TestDetectTokenTypeCaching for the new key; add regression tests.
* fix(security): bound introspection cache + cookie lifetime to config
Batch 7 of security audit remediation (ranks 8, 13).
- token_introspection.go: when requireTokenIntrospection is enabled, cap the
positive introspection-result cache at 30s (instead of 5m) so a token
revoked at the provider stops passing within ~30s, matching the operator's
near-real-time revocation expectation (rank 8).
- session.go: bind the cookie store's MaxAge to the configured sessionMaxAge,
so the cookie codec's cryptographic timestamp validity is no longer fixed at
gorilla's 30-day default; a stolen cookie is valid only for the configured
session lifetime (rank 13).
- tests: add a cookie-lifetime regression test.
* fix(security): low-severity hardening (cache, DoS caps, PKCE, throttle)
Batch 8 of security audit remediation — low severity
(ranks 24, 25, 27, 29, 31, 36, 37, 41, 45, 46, 49).
- universal_cache.go: updateLocalCache updates an existing key in place instead
of orphaning its LRU element and double-counting currentSize/currentMemory
(rank 36 — the only production-reachable bug in this batch).
- jwk.go / metadata_cache.go / token_introspection.go: bound response bodies
with io.LimitReader (1 MiB) to prevent memory exhaustion from a hostile or
buggy provider (ranks 24, 25).
- jwk.go: skip JWKs not usable for signature verification (use != sig, or
key_ops without "verify") when building the key set (rank 49).
- auth_flow.go: fail closed at the callback when PKCE is enabled but the code
verifier is missing, instead of silently dropping it (rank 27).
- utilities.go / main.go: match allowedUserDomains case-insensitively (rank 31).
- bearer_auth.go: a single success no longer wipes an active per-IP penalty;
the counter resets only when no penalty is in effect (rank 29).
- main.go: handle (not discard) the NewSessionManager error (rank 37).
- error_recovery.go: take a write lock in isServiceDegraded (it deletes from a
map); compare retryable-error substrings case-insensitively (ranks 45, 46).
- singleton_resources.go: bind the generic-cache cleanup goroutine to the
resource-manager shutdown channel so it cannot outlive its owner (rank 41).
- tests: update the bearer throttle test to the corrected penalty semantics.
* fix(security): header sanitization, issuer pinning, fail-closed paths
Batch 9 of security audit remediation (ranks 18, 19, 20, 21, 22, 30, 33, 34).
- middleware.go / bearer_auth.go: sanitize claim-derived values on the cookie
auth path before injecting them into downstream headers. Drop group/role and
identifier values containing control chars, bidi-override runes, or the
, ; = delimiters (a comma would inject phantom entries into X-User-Groups);
reject control/bidi/over-length in rendered templated header output (but
permit , ; = in free-form values such as a bearer token). The bearer path
already sanitized; the cookie path did not (ranks 33, 34).
- main.go / metadata_cache.go: pin the discovered issuer to the configured
provider host (sameHost) and refuse/never-cache a mismatch, so a poisoned
discovery document cannot redefine the JWT trust anchor (ranks 21, 22).
- token_introspection.go: when a distinct API audience is configured, fail
closed on a missing or mismatched introspection audience; aud parsed as
string-or-array per RFC 7662 (rank 19).
- logout.go: front-channel logout requires a matching issuer; an empty iss is
rejected (blocks unauthenticated forced-logout via a known sid) (rank 30).
- token_validation_rs.go: an opaque access token with no ID token and no
successful introspection fails closed (re-auth) instead of authenticating
(ranks 18, 20).
- tests: realistic same-host provider mocks; regression tests for the header
sanitization distinction and the fail-closed paths.
* chore(security): remove unwired dead code with latent footguns
Batch 10 of security audit remediation — delete confirmed-dead, unwired
subsystems (ranks 26, 35, 50). None had a production caller (grep-verified);
removal eliminates the latent footguns and ~2.1k lines of dead code.
- token_validator.go (deleted): an unused *TokenValidator whose validateJWT set
Valid=true with NO signature verification — a severe footgun if ever wired
(rank 50). The wired RS-aware validators are unaffected.
- security_monitoring.go (deleted): an unused *SecurityMonitor / ExtractClientIP
that trusted spoofable X-Forwarded-For / X-Real-IP. The live bearer throttle
uses clientIPForBearer (RemoteAddr-only), unchanged (rank 35).
- dynamic_client_registration.go: removed the RFC 7592 management methods
(Update/Read/DeleteClientRegistration) that dereferenced an attacker-
influenced RegistrationClientURI with the registration token attached and no
HTTPS/SSRF gate, and had no callers. The wired RFC 7591 RegisterClient and
credential-store helpers are kept (rank 26).
- tests: removed the tests covering the deleted code.
* chore: add Makefile with yaegi load validation
No Makefile existed. The new `yaegi-validate` target interprets the plugin
under the yaegi interpreter the same way Traefik loads it, catching yaegi-only
incompatibilities (unsupported stdlib symbols, reflection edge cases) that the
native `go build` / `go test` toolchain does not. Importing the plugin forces
yaegi to interpret every file plus its vendored deps; CreateConfig + New
exercise the instantiation path.
- cmd/yaegicheck/main.go: the load driver, marked //go:build ignore so it is
excluded from `go build ./...` (avoids VCS-stamping a main binary, which
fails in git-worktree layouts) yet is run explicitly by yaegi.
- Makefile: build / fmt / vet / lint / test / vendor / yaegi-validate / check
targets; `make check` runs vet + tests + yaegi-validate.
Verified: `make yaegi-validate` passes on this branch — the HKDF cookie
encryption, net-based endpoint validation, and claim sanitizers all interpret
and instantiate cleanly under yaegi.
* ci: bump workflow Go toolchain to 1.25; pin yaegi-validate to v0.16.1
Traefik v3.7.1 (the deployed version) is built with `go 1.25.0`, so the PR and
release workflows now use Go 1.25.x to match the toolchain Traefik uses.
Important distinction: the CI Go version is the build TOOLCHAIN. The plugin's
actual interpreter-compatibility ceiling is the yaegi version Traefik bundles
(v0.16.1, which declares go 1.21 and ships a ~Go 1.22 stdlib symbol surface),
NOT the CI Go version. That ceiling is enforced by `make yaegi-validate` plus
the go.mod language directive — e.g. it is why HKDF is hand-rolled with
hmac+sha256 rather than Go 1.24's crypto/hkdf, which yaegi v0.16.1 lacks.
Also pin Makefile YAEGI_VERSION to v0.16.1 (what Traefik v3.7.1 vendors) so
yaegi-validate exercises the real deployed interpreter instead of @latest,
which could pass on a newer yaegi that supports symbols the deployed one does
not.
* docs: align README/CONFIGURATION with branch behavior changes
- excludedURLs: documented as segment/extension-boundary matching (was
"prefix-matched") — "/public" no longer also matches "/publicsecret" (rank 14).
- Front-channel logout now requires a matching `iss`; requests without one are
rejected with 400 (rank 30).
- Add an "Upgrading from an earlier release" note: session cookies are now
AES-256 encrypted with lifetime tracking sessionMaxAge (one-time re-login on
upgrade), and invalid configuration (rateLimit < 10, key < 32 bytes, missing
callbackURL, non-HTTPS remote providerURL) now fails closed at startup.
* fix: remove staticcheck-flagged unused functions; wire staticcheck into make check
CI Static Analysis (standalone staticcheck) failed with U1000 "unused":
- dynamic_client_registration.go: deleteCredentialsFromStore — its only caller
was the RFC 7592 DeleteClientRegistration removed in the dead-code batch.
- token_test.go: createTestJWTSimple — its only callers were the TokenValidator
tests removed in the same batch.
Both confirmed to have zero remaining callers and removed. build / vet /
go test ./... / staticcheck ./... all green.
The pre-commit hook runs golangci-lint, but CI runs standalone staticcheck
(which flags U1000). Add a `staticcheck` Makefile target and include it in
`make check` so this class of finding is caught locally before push.
* fix(test): stabilize flaky TestWorkerPool_TaskPanic
tasksFailed is incremented in the worker's deferred recover(), which runs after the panicking task's own defer wg.Done(). wg.Wait() could therefore return before the failure was recorded, so reading the counter immediately raced and flaked on slow CI runners. Poll until the failure lands (2s budget) instead. Verified 200x plain + 50x under -race/GOMAXPROCS=1.
1287 lines
50 KiB
Go
1287 lines
50 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"`
|
|
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
|
|
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")
|
|
}
|
|
if !strings.Contains(header.Value, "{{") || !strings.Contains(header.Value, "}}") {
|
|
return fmt.Errorf("header value '%s' does not appear to be a valid template (missing {{ }})", header.Value)
|
|
}
|
|
|
|
// 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); err != nil {
|
|
return fmt.Errorf("header template '%s' failed security validation: %w", header.Value, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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.
|
|
func validateTemplateSecure(templateStr string) error {
|
|
// Allow our specific safe custom functions
|
|
// These are added specifically to handle missing fields safely (issue #60)
|
|
safeCustomFunctions := []string{
|
|
"{{get ", // Safe map access function
|
|
"{{default ", // Safe default value function
|
|
}
|
|
|
|
// Check if template uses safe custom functions
|
|
usesSafeFunctions := false
|
|
for _, safeFn := range safeCustomFunctions {
|
|
if strings.Contains(templateStr, safeFn) {
|
|
usesSafeFunctions = true
|
|
// These functions are explicitly allowed for safe field access
|
|
}
|
|
}
|
|
|
|
// Check for dangerous template functions and patterns
|
|
// Skip certain checks if using our safe functions
|
|
dangerousPatterns := []string{
|
|
"{{call", // Function calls (except our safe ones)
|
|
"{{range", // Range over arbitrary data
|
|
"{{define", // Template definitions
|
|
"{{template", // Template inclusions
|
|
"{{block", // Block definitions
|
|
"{{/*", // Comments that could hide malicious code
|
|
"{{-", // Trim whitespace (could be used to obfuscate)
|
|
"-}}", // Trim whitespace (could be used to obfuscate)
|
|
"{{printf", // Printf functions
|
|
"{{print", // Print functions (but not our safe ones)
|
|
"{{println", // Println functions
|
|
"{{html", // HTML functions
|
|
"{{js", // JavaScript functions
|
|
"{{urlquery", // URL query functions
|
|
"{{index", // Index access to arbitrary data
|
|
"{{slice", // Slice operations
|
|
"{{len", // Length operations on arbitrary data
|
|
"{{eq", // Comparison operations
|
|
"{{ne", // Comparison operations
|
|
"{{lt", // Comparison operations
|
|
"{{le", // Comparison operations
|
|
"{{gt", // Comparison operations
|
|
"{{ge", // Comparison operations
|
|
"{{and", // Logical operations
|
|
"{{or", // Logical operations
|
|
"{{not", // Logical operations
|
|
}
|
|
|
|
// Allow 'with' for safe conditional access
|
|
if !strings.Contains(templateStr, "{{with .Claims") {
|
|
dangerousPatterns = append(dangerousPatterns, "{{with")
|
|
}
|
|
|
|
templateLower := strings.ToLower(templateStr)
|
|
for _, pattern := range dangerousPatterns {
|
|
// Skip check if it's one of our safe functions
|
|
if usesSafeFunctions && (pattern == "{{call" || pattern == "{{print") {
|
|
// Allow these if we're using safe functions
|
|
continue
|
|
}
|
|
|
|
// Special handling for comparison operators to avoid false positives with "get" and "default"
|
|
if pattern == "{{ge" && (strings.Contains(templateStr, "{{get ") || strings.Contains(templateStr, "{{default ")) {
|
|
// Skip {{ge check if we're using the safe {{get or {{default functions
|
|
continue
|
|
}
|
|
|
|
// Skip {{de checks if using {{default
|
|
if pattern == "{{define" && strings.Contains(templateStr, "{{default ") {
|
|
continue
|
|
}
|
|
|
|
if strings.Contains(templateLower, strings.ToLower(pattern)) {
|
|
return fmt.Errorf("dangerous template pattern detected: %s", pattern)
|
|
}
|
|
}
|
|
|
|
// Validate template variables against whitelist
|
|
allowedPatterns := []string{
|
|
"{{.AccessToken}}",
|
|
"{{.IdToken}}",
|
|
"{{.RefreshToken}}",
|
|
"{{.Claims.",
|
|
"{{get ", // Safe custom function
|
|
"{{default ", // Safe custom function
|
|
"{{with ", // Safe conditional (when used with Claims)
|
|
}
|
|
|
|
// Check if template contains only allowed patterns
|
|
hasAllowedPattern := false
|
|
for _, pattern := range allowedPatterns {
|
|
if strings.Contains(templateStr, pattern) {
|
|
hasAllowedPattern = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !hasAllowedPattern {
|
|
return fmt.Errorf("template must use only allowed variables: AccessToken, IdToken, RefreshToken, Claims.*, or safe functions (get, default, with)")
|
|
}
|
|
|
|
// Validate claims access patterns
|
|
if strings.Contains(templateStr, "{{.Claims.") {
|
|
// Simple validation - ensure claims access is to known safe fields
|
|
// This list includes standard OIDC claims and common provider-specific claims
|
|
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
|
|
}
|
|
|
|
// Extract field names from Claims access
|
|
start := strings.Index(templateStr, "{{.Claims.")
|
|
for start != -1 {
|
|
end := strings.Index(templateStr[start:], "}}")
|
|
if end == -1 {
|
|
return fmt.Errorf("malformed Claims template syntax")
|
|
}
|
|
|
|
// Extract the content between "{{.Claims." and "}}"
|
|
// start+10 skips "{{.Claims." and start+end is the position of "}}"
|
|
claimsContent := templateStr[start+10 : start+end]
|
|
|
|
// Get the field name (first part before any dots)
|
|
fieldName := strings.Split(claimsContent, ".")[0]
|
|
|
|
if !safeClaimsFields[fieldName] {
|
|
return fmt.Errorf("access to Claims.%s is not allowed for security reasons", fieldName)
|
|
}
|
|
|
|
// Search for next occurrence
|
|
nextStart := strings.Index(templateStr[start+end+2:], "{{.Claims.")
|
|
if nextStart != -1 {
|
|
start = start + end + 2 + nextStart
|
|
} else {
|
|
start = -1
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prevent code injection through template syntax
|
|
if strings.Contains(templateStr, "{{") && strings.Contains(templateStr, "}}") {
|
|
// Count opening and closing braces
|
|
openCount := strings.Count(templateStr, "{{")
|
|
closeCount := strings.Count(templateStr, "}}")
|
|
if openCount != closeCount {
|
|
return fmt.Errorf("unbalanced template braces")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|