Compare commits

..
7 Commits
Author SHA1 Message Date
github-actions[bot] cd2226dc47 chore(release): v1.0.32 2026-08-09 01:02:16 +00:00
lukaszraczylo f728f61777 fix(config): wire introspectionURL override and repair dead endpoint overrides (#152)
Issue #152 asks for a manual introspection endpoint because some IdPs
omit introspection_endpoint from discovery. Analysis found the two
documented overrides revocationURL and oidcEndSessionURL were dead
code: the 1b49e13 rebuild dropped their constructor wiring.

Add the IntrospectionURL config field and wire all three overrides.
The overrides apply inside updateMetadataEndpoints, so they survive
every metadata refresh. A manual value always wins over discovery.
The constructor also seeds the live fields, so a failed discovery
does not lose operator configuration.

Manual values skip the poisoned-discovery defences on purpose. They
sit at the same trust tier as providerURL and isValidSecureURL gates
them at config time.

Add issue152_regression_test.go: 8 tests pin precedence, refresh
survival, cold-start seeding, end-to-end introspection over HTTP,
and the validation contract.
2026-08-09 01:59:47 +01:00
lukaszraczylo 78b6f193b3 fix(security): allow loopback hosts for loopback providerURL (#154)
Issue #154: a providerURL that points at a localhost IdP caused an
infinite redirect loop. validateHost rejected loopback hosts
unconditionally, so buildAuthURL returned an empty string.

Derive allowLoopbackHosts from a loopback providerURL at construction
time. Discovery validation already used this derivation, so the two
validation layers now agree. This adds no new config surface.

Cloud metadata hosts (169.254.169.254, metadata.google.internal),
link-local, and unspecified addresses stay blocked unconditionally.
Non-loopback providers keep identical behaviour.

Add issue154_regression_test.go: 8 tests pin the new allowance, the
default block, and the security envelope.
2026-08-09 00:47:25 +01:00
edb7174cc8 docs: pin documented plugin version to v1.0.29 (#153)
The install snippet in the README still pinned v0.7.10 and the example
configs v0.8.0, so anyone following the docs installed a release far
behind the current v1.0.29.

Co-authored-by: Markus Renschler <[email protected]>
2026-07-31 10:04:47 +01:00
lukaszraczylo e51bd9cdbe fix(catalog): analyzer-valid testData and explicit New() error return
The plugin catalog analyzer failed v1.0.28 with "invalid handler
type: <nil>" (#151). Two defects combined.

The analyzer feeds .traefik.yml testData to New() verbatim. The header
values used quadruple braces on a false premise: no configuration path
collapses them. Template validation rejected them, so New() failed. The
testData now carries plain, valid templates.

New() also returned failures through a multi-value tail call
(return NewWithContext(...)). Yaegi v0.16.1 zeroes both results of that
statement on the error path. The analyzer therefore saw (nil, nil) and
reported the opaque handler-type message instead of the real validation
error. New() now splits the return explicitly. Construction failures
surface as real error messages under yaegi and in Traefik logs.

The regression tests pin both fixes. README and CONFIGURATION.md now
give correct escaping guidance: plain double braces everywhere; file
provider users escape with a raw string. Quadruple braces do not work
anywhere.

Fixes #151.
2026-07-22 10:22:35 +01:00
lukaszraczyloandGitHub f320d0a316 fix(settings): repair #149 + AST-based header template validation + allowedClaims (#150)
* 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).
2026-07-21 13:04:39 +01:00
lukaszraczylo 48a19e04f8 fix(settings): tolerate whitespace in header template delimiters (#148)
validateTemplateSecure matched safe functions with literal strings.Contains
("{{get "), so a space after {{ ("{{ get") failed the whitelist and
Config.Validate() rejected the config, surfacing as Traefik's opaque
"invalid handler type: <nil>". Go's text/template treats {{ get and {{get
identically, so the validator was stricter than the engine.

Normalize inner-delimiter whitespace before matching. This also closes a
security gap where a leading space let dangerous patterns ("{{ range",
"{{ call") evade detection.

Adds unit + regression coverage.
2026-06-26 11:49:31 +01:00
19 changed files with 2178 additions and 339 deletions
+10 -5
View File
@@ -60,14 +60,18 @@ testData:
scopes:
- roles
# Templated headers forwarded to backends.
# NOTE: use quadruple braces — the YAML parser collapses {{{{ → {{ so the
# Go template engine receives the correct expression.
# Templated headers forwarded to backends. Values are Go templates and must
# be written with plain double braces — the catalog analyzer feeds them to
# the plugin verbatim (issue #151; quadruple braces do NOT get collapsed).
# Only when your dynamic config file is rendered by Traefik's file-provider
# Go templating do the braces need escaping there, e.g.
# value: "{{`{{.Claims.email}}`}}"
# (see README "Templated headers").
headers:
- name: X-User-Email
value: "{{{{.Claims.email}}}}"
value: "{{.Claims.email}}"
- name: Authorization
value: "Bearer {{{{.AccessToken}}}}"
value: "Bearer {{.AccessToken}}"
# Security headers (default profile is enabled out of the box)
securityHeaders:
@@ -96,6 +100,7 @@ testData:
# bearerEmitWWWAuthenticate: true # default; RFC 6750 hint on 401s
# bearerOverridesCookie: false # default; cookie wins when both are present
# requireTokenIntrospection: false # opt-in; calls RFC 7662 introspection per request
# introspectionURL: https://idp.example.com/oauth/introspect # manual override; set when discovery omits introspection_endpoint
# maxTokenAgeSeconds: 86400 # 24h cap on iat (rejects clock-skew/forever tokens)
# maxIdentifierLength: 256 # cap on the sanitised principal identifier
# bearerFailureThreshold: 20 # consecutive 401s/IP that trip the throttle
+41 -12
View File
@@ -42,7 +42,7 @@ experimental:
plugins:
traefikoidc:
moduleName: github.com/lukaszraczylo/traefikoidc
version: v0.7.10
version: v1.0.29
```
Then attach the middleware in your dynamic configuration (see
@@ -372,21 +372,50 @@ Forward identity to backends via Go templates over ID-token claims and tokens:
```yaml
headers:
- name: X-User-Email
value: "{{{{.Claims.email}}}}"
value: "{{.Claims.email}}"
- name: Authorization
value: "Bearer {{{{.AccessToken}}}}"
value: "Bearer {{.AccessToken}}"
- name: X-User-Roles
value: "{{{{range $i, $e := .Claims.roles}}}}{{{{if $i}}}},{{{{end}}}}{{{{$e}}}}{{{{end}}}}"
value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
```
Available bindings: `.Claims.<field>`, `.AccessToken`, `.IdToken`,
`.RefreshToken`. Names are case-sensitive (`.Claims`, not `.claims`).
Available bindings: `.Claims.<field>`, `.AccessToken`, `.IdToken`
(or `.IDToken`), `.RefreshToken`. Names are case-sensitive (`.Claims`, not
`.claims`).
> **Escape with quadruple braces.** If you see
> `can't evaluate field AccessToken in type bool`, Traefik's YAML parser ate
> your `{{ }}`. The fix that actually works is `{{{{ }}}}` — the YAML pass
> turns it into `{{ }}` for the Go template engine. Other escaping tricks
> (literal blocks, single quotes) do not work reliably.
Header templates are validated at startup (a failing template stops the
middleware from loading). Only a fixed set of claim fields may be emitted —
standard OIDC claims plus common provider claims (`email`, `name`,
`given_name`, `family_name`, `preferred_username`, `sub`, `groups`, `roles`,
`realm_access`, `resource_access`, `oid`, `tid`, `upn`, `hd`, `picture`,
`locale`, `email_verified`, and a few more; see `safeClaimsFields` in
`template_validation.go`). To emit a claim not on that list, add it to
`allowedClaims`:
```yaml
allowedClaims:
- employee_id
headers:
- name: X-Employee-Id
value: "{{.Claims.employee_id}}"
```
Rendering the whole context (`{{.}}`, `{{$}}`), the whole claims map
(`{{.Claims}}`), or any non-listed claim is rejected — this prevents a template
from accidentally forwarding raw tokens or unlisted claims. `range`/`with` must
target a specific listed claim (e.g. `{{range .Claims.groups}}`); `get`/`default`
are the only functions allowed.
> **File-provider users: escape the braces.** Traefik's file provider runs
> every dynamic configuration file through Go templating before the plugin
> sees it. Plain `{{.AccessToken}}` then fails with
> `can't evaluate field AccessToken in type bool`. Wrap the expression in a
> raw string so the file provider emits it literally:
> ``value: "{{`{{.Claims.email}}`}}"``. All other providers (Kubernetes CRD,
> Docker labels, Consul, ...) pass the value through untouched — use the plain
> form there. Quadruple braces (`{{{{ }}}}`) do not work anywhere: the file
> provider fails to parse them, and every other path hands them to the plugin
> verbatim, where template validation rejects them (issues #149, #151).
## Default downstream headers
@@ -414,7 +443,7 @@ section — see [docs/CONFIGURATION.md](docs/CONFIGURATION.md#security-headers).
| `No matching public key found` | JWKS endpoint down, or `kid` mismatch. |
| `Access denied: Your email domain is not allowed` | User's domain not in `allowedUserDomains`. |
| `Access denied: You do not have any of the allowed roles or groups` | Claims missing or not in `allowedRolesAndGroups`. |
| `can't evaluate field AccessToken in type bool` | Template not escaped — use `{{{{ }}}}`. |
| `can't evaluate field AccessToken in type bool` | File provider templated your header value — escape it: ``"{{`{{.AccessToken}}`}}"`` (see "Templated headers"). |
| `tls: failed to verify certificate: x509: certificate signed by unknown authority` | Internal CA — set `caCertPath` / `caCertPEM`. |
| `invalid handler type: <nil>` | Env var name contains `API` — rename it. |
| `false positive replay detected` | Multi-replica without Redis — see [Multi-replica deployments](#multi-replica-deployments). |
+43 -1
View File
@@ -750,11 +750,53 @@ func TestValidateTemplateSecure_CoverageBoost(t *testing.T) {
template: "{{.Unknown}}",
shouldError: true,
},
// Issue #148: whitespace after {{ and before }} must be tolerated
{
name: "get with leading space (issue #148)",
template: "{{ get .Claims \"email\" }}",
shouldError: false,
},
{
name: "get with multiple leading spaces",
template: "{{ get .Claims \"email\"}}",
shouldError: false,
},
{
name: "default with spaces",
template: "{{ default \"unknown\" .Claims.email }}",
shouldError: false,
},
{
name: "claims with spaces",
template: "{{ .Claims.email }}",
shouldError: false,
},
{
name: "access token with spaces",
template: "{{ .AccessToken }}",
shouldError: false,
},
// Issue #148: space must NOT let dangerous patterns bypass detection
{
name: "range with leading space stays blocked",
template: "{{ range .Items }}{{.}}{{end}}",
shouldError: true,
},
{
name: "call with leading space stays blocked",
template: "{{ call .Func }}",
shouldError: true,
},
{
name: "disallowed claim with spaces stays blocked",
template: "{{ .Claims.password }}",
shouldError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateTemplateSecure(tt.template)
err := validateTemplateSecure(tt.template, nil)
if tt.shouldError && err == nil {
t.Errorf("Expected error for template: %s", tt.template)
}
+9 -2
View File
@@ -298,13 +298,20 @@ strictAudienceValidation: true
```
Look for `introspection_endpoint`
2. **Disable required introspection (less secure):**
2. **Configure the endpoint manually:**
```yaml
introspectionURL: https://YOUR_DOMAIN/oauth/introspect
```
Set this when Auth0 omits `introspection_endpoint` from discovery. The
manual value takes precedence over the discovered value.
3. **Disable required introspection (less secure):**
```yaml
allowOpaqueTokens: true
requireTokenIntrospection: false # Falls back to ID token
```
3. **Use JWT access tokens instead** (recommended)
4. **Use JWT access tokens instead** (recommended)
---
+50 -9
View File
@@ -179,8 +179,9 @@ clientSecret: your-client-secret
| `forceHTTPS` | bool | `true` | Force HTTPS for redirect URIs (set `false` only for plaintext HTTP local dev) |
| `rateLimit` | int | `100` | Maximum requests per second |
| `excludedURLs` | []string | none | Paths that bypass authentication, matched at a path-segment or file-extension boundary |
| `revocationURL` | string | auto-discovered | Token revocation endpoint |
| `oidcEndSessionURL` | string | auto-discovered | Provider's end session endpoint |
| `revocationURL` | string | auto-discovered | Token revocation endpoint. Takes precedence over the discovered value. |
| `oidcEndSessionURL` | string | auto-discovered | Provider's end session endpoint. Takes precedence over the discovered value. |
| `introspectionURL` | string | auto-discovered | RFC 7662 token introspection endpoint. Set this when your IdP omits `introspection_endpoint` from discovery. Takes precedence over the discovered value. |
| `enablePKCE` | bool | `false` | Enable PKCE for authorization code flow |
| `minimalHeaders` | bool | `false` | Reduce forwarded headers |
| `clientAuthMethod` | string | `client_secret_post` | Client authentication method at token/revocation endpoints. One of `client_secret_post`, `client_secret_basic`, `private_key_jwt`. See [Client Authentication](#client-authentication). |
@@ -431,22 +432,62 @@ minimalHeaders: true # Only forwards X-Forwarded-User
```yaml
headers:
- name: "X-User-Email"
value: "{{{{.Claims.email}}}}"
value: "{{.Claims.email}}"
- name: "X-User-ID"
value: "{{{{.Claims.sub}}}}"
value: "{{.Claims.sub}}"
- name: "Authorization"
value: "Bearer {{{{.AccessToken}}}}"
value: "Bearer {{.AccessToken}}"
- name: "X-User-Roles"
value: "{{{{range $i, $e := .Claims.roles}}}}{{{{if $i}}}},{{{{end}}}}{{{{$e}}}}{{{{end}}}}"
value: "{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}"
```
**Template Variables:**
- `{{.Claims.field}}` - ID token claims
- `{{.Claims.field}}` - ID token claims (only whitelisted fields; see below)
- `{{.AccessToken}}` - Raw access token
- `{{.IdToken}}` - Raw ID token
- `{{.IdToken}}` / `{{.IDToken}}` - Raw ID token (both spellings work)
- `{{.RefreshToken}}` - Raw refresh token
**Important:** Use double curly braces (`{{{{` and `}}}}`) to escape templates in YAML.
**Important — file provider only:** Traefik's file provider runs every dynamic
configuration file through Go templating before the plugin sees it, so plain
`{{ }}` fails there with `can't evaluate field AccessToken in type bool`.
Escape with a raw string: ``value: "{{`{{.Claims.email}}`}}"``. All other
providers (Kubernetes CRD, Docker labels, Consul, ...) pass the value through
untouched — use the plain form shown above. Do not use quadruple braces
(`{{{{ }}}}`); no configuration path collapses them, and template validation
rejects them (issues #149, #151).
**Validation and the claims whitelist.** Header templates are parsed and
validated when the middleware loads; an invalid or disallowed template stops the
middleware from starting (the route then returns Traefik's
`invalid handler type: <nil>`). To prevent a template from accidentally
forwarding raw tokens or sensitive claims, only a fixed set of claim fields may
be emitted — the standard OIDC claims plus common provider claims (`email`,
`name`, `given_name`, `family_name`, `preferred_username`, `sub`, `groups`,
`roles`, `internal_role`, `role`, `department`, `organization`, `realm_access`,
`resource_access`, `oid`, `tid`, `upn`, `hd`, `picture`, `locale`, `zoneinfo`,
`phone_number`, `email_verified`, `updated_at`; the authoritative list is
`safeClaimsFields` in `template_validation.go`). Rejected: rendering the whole
context (`{{.}}`, `{{$}}`) or the whole claims map (`{{.Claims}}`), any claim not
on the list, functions other than `get`/`default`, and `range`/`with` that do not
target a specific listed claim (e.g. `{{range .Claims}}` is rejected; `{{range
.Claims.groups}}` is allowed).
**`allowedClaims`** extends the whitelist for claims your provider issues that
are not built in:
```yaml
allowedClaims:
- employee_id
- cost_center
headers:
- name: "X-Employee-Id"
value: "{{.Claims.employee_id}}"
```
Listed names apply to both direct access (`{{.Claims.employee_id}}`) and
`get` (`{{get .Claims "employee_id"}}`). Subfields of a whitelisted map-valued
claim (e.g. `realm_access.roles`) are already accessible without listing them
individually.
---
+2 -2
View File
@@ -51,7 +51,7 @@ experimental:
plugins:
traefikoidc:
moduleName: github.com/lukaszraczylo/traefikoidc
version: v0.8.0
version: v1.0.29
log:
level: INFO
@@ -263,7 +263,7 @@ services:
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--experimental.plugins.traefikoidc.modulename=github.com/lukaszraczylo/traefikoidc"
- "--experimental.plugins.traefikoidc.version=v0.8.0"
- "--experimental.plugins.traefikoidc.version=v1.0.29"
ports:
- "80:80"
- "443:443"
+1 -1
View File
@@ -6,7 +6,7 @@ experimental:
plugins:
traefikoidc:
moduleName: github.com/lukaszraczylo/traefikoidc
version: v0.8.0
version: v1.0.29
# Dynamic configuration (dynamic.yml or labels)
http:
+303
View File
@@ -0,0 +1,303 @@
package traefikoidc
import (
"bytes"
"strings"
"testing"
"text/template"
)
// Issue #149: after NewWithContext started calling config.Validate() (commit
// 546ceb9), `headers` configs that had worked in 0.8.25 began failing, so New()
// returned a nil handler and Traefik logged the opaque router error
// "invalid handler type: <nil>". Two independent header-validation checks were
// too strict:
//
// 1. validateTemplateSecure rejected the documented multi-valued-claim join
// pattern "{{range .. := .Claims.groups}}..." as a dangerous {{range}}.
// 2. Config.Validate required every header value to contain {{ }}, rejecting
// static/literal values (e.g. a shared proxy secret).
//
// These tests pin the fix while proving the security boundary still holds.
// issue149BaseConfig returns a complete, valid config with no headers. Header
// cases are layered on per-test. No network is performed by Config.Validate.
func issue149BaseConfig() *Config {
return &Config{
ProviderURL: "https://provider.example.com",
ClientID: "frigate-client",
ClientSecret: "frigate-client-secret",
CallbackURL: "/oauth2/callback",
SessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789",
RateLimit: 100,
Scopes: []string{"openid", "profile", "email", "groups"},
}
}
// TestIssue149_HeadersConfigValidates is the exact reproduction: the issue's
// three headers (after Traefik file-provider un-escaping) must now validate.
func TestIssue149_HeadersConfigValidates(t *testing.T) {
cfg := issue149BaseConfig()
cfg.Headers = []TemplatedHeader{
{Name: "X-Forwarded-Preferred-Username", Value: `{{.Claims.preferred_username}}`},
{Name: "X-Forwarded-Groups", Value: `{{range $i, $e := .Claims.groups}}{{if $i}},{{end}}{{$e}}{{end}}`},
{Name: "X-Proxy-Secret", Value: `super-secret-shared-value`},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("issue #149 headers config must validate after the fix, got: %v", err)
}
}
// TestIssue149_TemplateSecurityBoundary asserts the relaxed validator accepts
// the documented range/if join over claims while still blocking genuinely
// dangerous templates and access to non-whitelisted claims.
func TestIssue149_TemplateSecurityBoundary(t *testing.T) {
cases := []struct {
name string
template string
shouldFail bool
}{
// --- allowed: documented, safe patterns ---
{"direct claim", `{{.Claims.preferred_username}}`, false},
{"documented groups join", `{{range $i, $e := .Claims.groups}}{{if $i}},{{end}}{{$e}}{{end}}`, false},
{"documented roles join", `{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}`, false},
{"simple range over claims", `{{range .Claims.groups}}{{.}} {{end}}`, false},
{"default over claim", `{{default "none" .Claims.email}}`, false},
{"access token", `{{.AccessToken}}`, false},
{"with over claim field", `{{with .Claims.email}}{{.}}{{end}}`, false},
{"id token IdToken spelling", `{{.IdToken}}`, false}, // documented spelling (C6)
{"id token IDToken spelling", `{{.IDToken}}`, false}, // actual data key (C6)
// --- still blocked: with over the bare Claims map leaks arbitrary fields ---
{"with over bare claims map", `{{with .Claims}}{{.password}}{{end}}`, true},
// --- still blocked: dangerous control/functions ---
{"range over non-claims data", `{{range .Items}}{{.}}{{end}}`, true},
{"call function", `{{call .Func}}`, true},
{"define template", `{{define "x"}}{{.}}{{end}}`, true},
{"template inclusion", `{{template "other"}}`, true},
{"printf", `{{printf "%s" .}}`, true},
// --- still blocked: non-whitelisted claim access, incl. via range ---
{"non-whitelisted claim direct", `{{.Claims.password}}`, true},
{"non-whitelisted claim via range", `{{range $i, $e := .Claims.ssn}}{{$e}}{{end}}`, true},
{"no recognized variable", `{{.Unknown}}`, true},
// --- still blocked: whole-Claims-map dump via range (issue #149 review, C1).
// A decoy {{.Claims.sub}} must NOT launder a bare-map range past the gate.
{"whole claims map dump via range", `{{range .Claims}}{{.}} {{end}}{{.Claims.sub}}`, true},
{"claims keys dump via range assign", `{{range $k, $v := .Claims}}{{$k}} {{end}}{{.Claims.sub}}`, true},
// --- still blocked: newline-obfuscated actions — the AST parser resolves
// whitespace, so these are just {{range}}/{{index}} and are rejected.
{"newline-obfuscated range over map", "{{\nrange .Claims}}{{.}}{{end}}{{.Claims.sub}}", true},
{"newline-obfuscated index exfil", "{{\nindex .Claims \"password\"}}{{.Claims.sub}}", true},
// --- still blocked: whole-root / whole-Claims-map render (AST review). A
// whitelisted decoy {{.Claims.email}} must NOT launder a bare-map/root dump.
{"whole claims map render", `{{.Claims}}`, true},
{"whole root render dot", `{{.}}`, true},
{"whole root render dollar", `{{$}}`, true},
{"root claims via dollar", `{{$.Claims}}`, true},
{"decoy plus root dump", `{{.Claims.email}}{{.}}`, true},
{"decoy plus map dump", `{{.Claims.email}}{{.Claims}}`, true},
{"var-laundered map dump", `{{$x := .Claims}}{{$x}}{{.Claims.sub}}`, true},
{"index builtin blocked", `{{index .Claims "password"}}`, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateTemplateSecure(tc.template, nil)
if tc.shouldFail && err == nil {
t.Fatalf("expected %q to be rejected, but it passed", tc.template)
}
if !tc.shouldFail && err != nil {
t.Fatalf("expected %q to be allowed, but it was rejected: %v", tc.template, err)
}
})
}
}
// TestIssue149_GroupsTemplateRendersForwardableValue proves the end goal: the
// documented groups join template parses, executes to a comma-separated string,
// and passes the runtime header sanitizer — so the header is actually emitted,
// not merely accepted by validation. Parse options mirror main.go:414.
func TestIssue149_GroupsTemplateRendersForwardableValue(t *testing.T) {
const tmplStr = `{{range $i, $e := .Claims.groups}}{{if $i}},{{end}}{{$e}}{{end}}`
tmpl, err := template.New("X-Forwarded-Groups").Option("missingkey=zero").Parse(tmplStr)
if err != nil {
t.Fatalf("documented groups template must parse: %v", err)
}
data := map[string]any{
"Claims": map[string]any{
"groups": []any{"admin", "users", "viewers"},
},
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
t.Fatalf("groups template must execute: %v", err)
}
if got := buf.String(); got != "admin,users,viewers" {
t.Fatalf("groups render mismatch: got %q want %q", got, "admin,users,viewers")
}
// The comma-joined value must survive the runtime injection guard; commas
// are not control/bidi runes, so it forwards intact.
if reason := headerValueReason(buf.String(), headerTemplateMaxLen); reason != "" {
t.Fatalf("rendered groups header was dropped by sanitizer: %s", reason)
}
}
// TestIssue149_GetEnforcesClaimsWhitelist proves the runtime `get` helper cannot
// exfiltrate a non-whitelisted claim, a raw token, or the whole data map — even
// though validateTemplateSecure accepts {{get ...}} syntactically. This is the
// defense-in-depth layer for C2/C3/C4 from the issue #149 security review.
func TestIssue149_GetEnforcesClaimsWhitelist(t *testing.T) {
data := map[string]any{
"AccessToken": "AT-SECRET",
"IDToken": "IDT",
"IdToken": "IDT",
"RefreshToken": "RT-SECRET",
"Claims": map[string]any{
"email": "[email protected]",
"password": "PW-SECRET",
"ssn": "123-45-6789",
},
}
fns := headerTemplateFuncMap(nil)
render := func(tmplStr string) string {
tmpl := template.Must(template.New("h").Funcs(fns).Option("missingkey=zero").Parse(tmplStr))
var b bytes.Buffer
if err := tmpl.Execute(&b, data); err != nil {
t.Fatalf("execute %q: %v", tmplStr, err)
}
return b.String()
}
// Every exfil attempt must render empty (or the default fallback), never a secret.
leaky := map[string]string{
"get non-whitelisted claim": `{{get .Claims "password"}}`,
"get ssn": `{{get .Claims "ssn"}}`,
"get raw token off root": `{{get . "AccessToken"}}`,
"get whole claims map": `{{get . "Claims"}}`,
"default wrapping get(ssn)": `{{default "none" (get .Claims "ssn")}}`,
}
for name, tmplStr := range leaky {
t.Run(name, func(t *testing.T) {
got := render(tmplStr)
for _, secret := range []string{"PW-SECRET", "AT-SECRET", "RT-SECRET", "123-45-6789"} {
if strings.Contains(got, secret) {
t.Fatalf("%q leaked %q (rendered %q)", tmplStr, secret, got)
}
}
})
}
// A whitelisted claim via get must still resolve.
if got := render(`{{get .Claims "email"}}`); got != "[email protected]" {
t.Fatalf(`get of whitelisted "email" must work, got %q`, got)
}
}
// TestIssue149_IdTokenBothSpellingsRender pins C6: both {{.IdToken}} (documented)
// and {{.IDToken}} (runtime data key) resolve to the ID token, and both validate.
func TestIssue149_IdTokenBothSpellingsRender(t *testing.T) {
data := map[string]any{"IDToken": "IDT-REAL", "IdToken": "IDT-REAL"}
for _, tmplStr := range []string{`{{.IdToken}}`, `{{.IDToken}}`} {
if err := validateTemplateSecure(tmplStr, nil); err != nil {
t.Fatalf("%q must validate: %v", tmplStr, err)
}
tmpl := template.Must(template.New("h").Option("missingkey=zero").Parse(tmplStr))
var b bytes.Buffer
if err := tmpl.Execute(&b, data); err != nil {
t.Fatalf("execute %q: %v", tmplStr, err)
}
if b.String() != "IDT-REAL" {
t.Fatalf("%q rendered %q, want IDT-REAL", tmplStr, b.String())
}
}
}
// TestIssue149_AllowedClaimsExtendsWhitelist pins the allowedClaims config
// option: a custom claim is rejected by default but validates and renders once
// listed, via both direct access and get.
func TestIssue149_AllowedClaimsExtendsWhitelist(t *testing.T) {
const custom = "employee_id"
// Default (no allowedClaims): the custom claim is not forwardable.
base := issue149BaseConfig()
base.Headers = []TemplatedHeader{{Name: "X-Emp", Value: `{{.Claims.employee_id}}`}}
if err := base.Validate(); err == nil {
t.Fatalf("custom claim %q must be rejected without allowedClaims", custom)
}
// With allowedClaims: direct access and get both validate.
cfg := issue149BaseConfig()
cfg.AllowedClaims = []string{custom}
cfg.Headers = []TemplatedHeader{
{Name: "X-Emp", Value: `{{.Claims.employee_id}}`},
{Name: "X-Emp-Get", Value: `{{get .Claims "employee_id"}}`},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("allowedClaims must permit %q, got: %v", custom, err)
}
// The whitelist (validation) and the runtime get share the same effective set.
whitelist := claimsWhitelist(cfg.AllowedClaims)
if err := validateTemplateSecure(`{{get .Claims "employee_id"}}`, whitelist); err != nil {
t.Fatalf("get on allowed custom claim must validate: %v", err)
}
fns := headerTemplateFuncMap(whitelist)
tmpl := template.Must(template.New("h").Funcs(fns).Option("missingkey=zero").Parse(`{{get .Claims "employee_id"}}`))
var b bytes.Buffer
if err := tmpl.Execute(&b, map[string]any{"Claims": map[string]any{"employee_id": "E-42"}}); err != nil {
t.Fatalf("render: %v", err)
}
if b.String() != "E-42" {
t.Fatalf("runtime get of allowed claim rendered %q, want E-42", b.String())
}
// A still-unlisted claim stays rejected even when another is allowed.
if err := validateTemplateSecure(`{{.Claims.ssn}}`, whitelist); err == nil {
t.Fatal("non-allowed claim ssn must still be rejected")
}
}
// TestIssue149_StaticAndDelimiterHandling covers the Config.Validate header loop
// for literal values and malformed delimiters (validateTemplateSecure is not
// invoked for pure literals).
func TestIssue149_StaticAndDelimiterHandling(t *testing.T) {
cases := []struct {
name string
value string
shouldFail bool
errContains string
}{
{"static literal secret", `super-secret-shared-value`, false, ""},
{"static with equals/base64", `AAAA1234==`, false, ""},
{"static with CRLF is rejected", "line1\r\nline2", true, "invalid characters"},
{"unbalanced open only", `{{.Claims.email`, true, "unbalanced template delimiters"},
{"unbalanced close only", `.Claims.email}}`, true, "unbalanced template delimiters"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := issue149BaseConfig()
cfg.Headers = []TemplatedHeader{{Name: "X-Test", Value: tc.value}}
err := cfg.Validate()
if tc.shouldFail {
if err == nil {
t.Fatalf("expected value %q to be rejected, but it passed", tc.value)
}
if tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) {
t.Fatalf("expected error to contain %q, got: %v", tc.errContains, err)
}
return
}
if err != nil {
t.Fatalf("expected value %q to be allowed, but it was rejected: %v", tc.value, err)
}
})
}
}
+114
View File
@@ -0,0 +1,114 @@
package traefikoidc
import (
"context"
"encoding/json"
"net/http"
"os"
"testing"
"gopkg.in/yaml.v3"
)
// Issue #151: the Traefik Plugin Catalog analyzer (piceus) failed v1.0.28 with
// "failed to run the plugin with Yaegi: invalid handler type: <nil>". Two
// independent defects combined to produce that opaque report:
//
// 1. The analyzer feeds .traefik.yml testData to New() RAW — paerser and
// mapstructure perform no brace collapsing, so the quadruple-brace header
// values ("{{{{.Claims.email}}}}") reached Config.Validate() verbatim and
// failed template parsing (`unexpected "{" in command`). The quadruple-brace
// convention (commit 2d1b04c) was based on a false premise; YAML does not
// collapse braces.
//
// 2. New() tail-called `return NewWithContext(...)`, converting the returned
// (*TraefikOidc, error) to (http.Handler, error) in the return statement.
// Under yaegi v0.16.1 (the interpreter piceus and Traefik embed) that
// conversion zeroes BOTH results when the error path is taken, so piceus
// saw (nil, nil), skipped its error branch, and reported the handler-type
// message instead of the real validation error. Compiled Go had the sibling
// defect: the typed-nil *TraefikOidc made New() return a NON-nil
// http.Handler alongside a non-nil error.
//
// These tests pin both fixes natively (no interpreter needed):
// TestIssue151_ManifestTestDataPassesAnalyzer pins (1) and
// TestIssue151_NewReturnsUntypedNilHandlerOnInvalidConfig pins (2).
// manifestTestData reads .traefik.yml and returns testData exactly as the
// analyzer sees it: parsed by a YAML parser, no template preprocessing.
func manifestTestData(t *testing.T) map[string]any {
t.Helper()
raw, err := os.ReadFile(".traefik.yml")
if err != nil {
t.Fatalf("read .traefik.yml: %v", err)
}
var manifest struct {
TestData map[string]any `yaml:"testData"`
}
if err := yaml.Unmarshal(raw, &manifest); err != nil {
t.Fatalf("parse .traefik.yml: %v", err)
}
if len(manifest.TestData) == 0 {
t.Fatal(".traefik.yml has no testData — the analyzer rejects manifests without it")
}
return manifest.TestData
}
// TestIssue151_ManifestTestDataPassesAnalyzer decodes the manifest's testData
// into a Config the same way piceus does (defaults from CreateConfig, then the
// raw testData values on top; JSON round-trip stands in for mapstructure) and
// requires the full analyzer contract: Validate() passes and New() yields a
// working handler with no error.
func TestIssue151_ManifestTestDataPassesAnalyzer(t *testing.T) {
testData := manifestTestData(t)
cfg := CreateConfig()
buf, err := json.Marshal(testData)
if err != nil {
t.Fatalf("marshal testData: %v", err)
}
if err := json.Unmarshal(buf, cfg); err != nil {
t.Fatalf("decode testData into Config: %v", err)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("manifest testData must pass Validate() — the catalog analyzer feeds it to New() raw (issue #151): %v", err)
}
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "issue151-analyzer")
if err != nil {
t.Fatalf("New() with manifest testData must succeed (issue #151): %v", err)
}
if h == nil {
t.Fatal("New() with manifest testData returned a nil handler")
}
if closer, ok := h.(interface{ Close() error }); ok {
_ = closer.Close()
}
}
// TestIssue151_NewReturnsUntypedNilHandlerOnInvalidConfig requires New() to
// return an UNTYPED nil handler when construction fails. The previous
// `return NewWithContext(...)` tail call wrapped a typed-nil *TraefikOidc into
// the http.Handler interface (h != nil despite err != nil), and under yaegi the
// same statement zeroed the error as well, which is what turned every
// validation failure into Traefik's opaque "invalid handler type: <nil>".
func TestIssue151_NewReturnsUntypedNilHandlerOnInvalidConfig(t *testing.T) {
cfg := issue149BaseConfig()
cfg.Headers = []TemplatedHeader{
// Invalid template — the analyzer-side shape that triggered #151.
{Name: "X-User-Email", Value: `{{{{.Claims.email}}}}`},
}
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "issue151-nil")
if err == nil {
t.Fatal("New() must reject an invalid header template")
}
if h != nil {
t.Fatalf("New() must return an untyped nil handler on error, got %T (typed nil leaks through the http.Handler interface and breaks the analyzer's error reporting)", h)
}
}
+568
View File
@@ -0,0 +1,568 @@
package traefikoidc
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// issue152_regression_test.go — regression tests for issue #152.
//
// Three provider endpoints — token revocation, OIDC end-session (logout), and
// RFC 7662 token introspection — were documented Config fields
// (RevocationURL, OIDCEndSessionURL) or a brand-new one (IntrospectionURL)
// that operators can set when their IdP omits an endpoint from discovery, or
// to pin a non-standard one. RevocationURL and OIDCEndSessionURL were dead:
// Config.Validate() checked them but nothing ever copied them onto the live
// TraefikOidc fields consumers read, so setting them silently did nothing.
// IntrospectionURL did not exist at all.
//
// The fix adds three unexported write-once fields on TraefikOidc
// (configRevocationURL, configEndSessionURL, configIntrospectionURL),
// populated from Config at construction (main.go NewWithContext). The same
// three values also cold-start-seed the live fields (revocationURL,
// endSessionURL, introspectionURL) so a discovery failure does not lose an
// operator override. Inside updateMetadataEndpoints, after the SSRF sanitize
// pass, the introspection same-host pin, and the issuer pin — but before the
// metadataMu.Lock() that publishes the live fields — each non-empty config*
// field overwrites the corresponding metadata.* value, so a manual override
// wins over discovery on every refresh, not just at construction. An empty
// override leaves the discovered value alone. Manual overrides deliberately
// skip validateDiscoveredEndpoint and the introspection same-host pin: they
// sit at the same trust tier as providerURL itself, already gated by
// isValidSecureURL at config-validation time.
//
// These tests pin: default (no overrides) behavior is unchanged, overrides
// win over both absent and present discovered values, a cross-host
// introspection override survives the same-host pin that a discovered
// cross-host endpoint does not, overrides survive repeated metadata
// refreshes, NewWithContext wires the three config* fields and cold-start
// seeds correctly (including when discovery fails outright), the override
// is honored end-to-end by introspectToken and RevokeTokenWithProvider, the
// logout URL builder emits the override, and Config.Validate / New enforce
// the same "invalid configuration" + untyped-nil-handler contract (#151)
// for a bad IntrospectionURL that the sibling fields already had.
// issue152BaseConfig returns a complete, valid Config, following the
// minimal-valid-config pattern used elsewhere in this package (e.g.
// issue154BaseConfig in issue154_regression_test.go).
func issue152BaseConfig() *Config {
return &Config{
ProviderURL: "https://provider.example.com",
ClientID: "test-client",
ClientSecret: "test-secret",
CallbackURL: "/callback",
SessionEncryptionKey: "this-is-a-valid-session-key-32b!",
RateLimit: 100,
}
}
// issue152Endpoints reads the three metadataMu-guarded live endpoint fields
// under lock, so callers stay race-clean regardless of whether a background
// discovery goroutine may still touch them.
func issue152Endpoints(oidc *TraefikOidc) (revocation, endSession, introspection string) {
oidc.metadataMu.RLock()
defer oidc.metadataMu.RUnlock()
return oidc.revocationURL, oidc.endSessionURL, oidc.introspectionURL
}
// TestIssue152DefaultPreservation proves the fix is additive: with no
// operator overrides, updateMetadataEndpoints behaves exactly as before —
// discovered values become the live fields and the published snapshot.
func TestIssue152DefaultPreservation(t *testing.T) {
t.Run("discovered values populate live fields and snapshot", func(t *testing.T) {
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
RevokeURL: "https://provider.example.com/revoke",
EndSessionURL: "https://provider.example.com/endsession",
IntrospectionURL: "https://provider.example.com/introspect",
RegistrationURL: "https://provider.example.com/register",
}
oidc.updateMetadataEndpoints(metadata)
revocation, endSession, introspection := issue152Endpoints(oidc)
if revocation != metadata.RevokeURL {
t.Fatalf("revocationURL = %q, want discovered %q", revocation, metadata.RevokeURL)
}
if endSession != metadata.EndSessionURL {
t.Fatalf("endSessionURL = %q, want discovered %q", endSession, metadata.EndSessionURL)
}
if introspection != metadata.IntrospectionURL {
t.Fatalf("introspectionURL = %q, want discovered %q", introspection, metadata.IntrospectionURL)
}
snap := oidc.metadataSnap()
if snap == nil {
t.Fatal("metadataSnap() returned nil after updateMetadataEndpoints")
}
if snap.RevocationURL != metadata.RevokeURL || snap.EndSessionURL != metadata.EndSessionURL || snap.IntrospectionURL != metadata.IntrospectionURL {
t.Fatalf("metadataSnapshot = %+v, want it to match discovered metadata %+v", snap, metadata)
}
})
t.Run("discovery omits introspection and no override leaves it empty", func(t *testing.T) {
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
// IntrospectionURL intentionally omitted.
}
oidc.updateMetadataEndpoints(metadata)
_, _, introspection := issue152Endpoints(oidc)
if introspection != "" {
t.Fatalf("introspectionURL = %q, want empty when discovery omits it and no override is set", introspection)
}
})
}
// TestIssue152OverridesWin proves a non-empty config* override always wins
// over discovery, whether discovery is silent on the endpoint (a) or
// actively supplies a different value (b), and that a cross-host
// introspection override survives the same-host pin that a discovered
// cross-host introspection endpoint does not (c).
func TestIssue152OverridesWin(t *testing.T) {
overrideRevocation := "https://override.example.com/revoke"
overrideEndSession := "https://override.example.com/endsession"
overrideIntrospection := "https://override.example.com/introspect"
newOverriddenOidc := func() *TraefikOidc {
return &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
configRevocationURL: overrideRevocation,
configEndSessionURL: overrideEndSession,
configIntrospectionURL: overrideIntrospection,
}
}
t.Run("discovery omits endpoints, overrides fill them in", func(t *testing.T) {
oidc := newOverriddenOidc()
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
}
oidc.updateMetadataEndpoints(metadata)
revocation, endSession, introspection := issue152Endpoints(oidc)
if revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("got (%q, %q, %q), want overrides (%q, %q, %q)",
revocation, endSession, introspection, overrideRevocation, overrideEndSession, overrideIntrospection)
}
snap := oidc.metadataSnap()
if snap == nil || snap.RevocationURL != overrideRevocation || snap.EndSessionURL != overrideEndSession || snap.IntrospectionURL != overrideIntrospection {
t.Fatalf("metadataSnapshot = %+v, want overrides", snap)
}
})
t.Run("discovery supplies different values, overrides win anyway", func(t *testing.T) {
oidc := newOverriddenOidc()
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
RevokeURL: "https://provider.example.com/discovered-revoke",
EndSessionURL: "https://provider.example.com/discovered-endsession",
IntrospectionURL: "https://provider.example.com/discovered-introspect",
}
oidc.updateMetadataEndpoints(metadata)
revocation, endSession, introspection := issue152Endpoints(oidc)
if revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("got (%q, %q, %q), want overrides (%q, %q, %q) to beat discovered values",
revocation, endSession, introspection, overrideRevocation, overrideEndSession, overrideIntrospection)
}
})
t.Run("cross-host introspection: discovered is zeroed, override survives", func(t *testing.T) {
// No override: a discovered introspection endpoint on a different host
// than providerURL must be zeroed by the same-host pin.
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
IntrospectionURL: "https://attacker.example.com/introspect",
}
oidc.updateMetadataEndpoints(metadata)
if _, _, introspection := issue152Endpoints(oidc); introspection != "" {
t.Fatalf("introspectionURL = %q, want empty: discovered cross-host introspection must be zeroed by the same-host pin", introspection)
}
// Same cross-host discovered value, but now with a cross-host override:
// the override must survive because it skips the same-host pin.
oidcWithOverride := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
configIntrospectionURL: overrideIntrospection,
}
oidcWithOverride.updateMetadataEndpoints(metadata)
if _, _, introspection := issue152Endpoints(oidcWithOverride); introspection != overrideIntrospection {
t.Fatalf("introspectionURL = %q, want override %q to survive despite being cross-host", introspection, overrideIntrospection)
}
})
}
// TestIssue152RefreshSurvival proves overrides are re-applied inside
// updateMetadataEndpoints itself, so they survive every metadata refresh —
// not just the first call after construction.
func TestIssue152RefreshSurvival(t *testing.T) {
overrideRevocation := "https://override.example.com/revoke"
overrideEndSession := "https://override.example.com/endsession"
overrideIntrospection := "https://override.example.com/introspect"
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
configRevocationURL: overrideRevocation,
configEndSessionURL: overrideEndSession,
configIntrospectionURL: overrideIntrospection,
}
firstRefresh := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
RevokeURL: "https://provider.example.com/refresh1-revoke",
EndSessionURL: "https://provider.example.com/refresh1-endsession",
IntrospectionURL: "https://provider.example.com/refresh1-introspect",
}
oidc.updateMetadataEndpoints(firstRefresh)
if revocation, endSession, introspection := issue152Endpoints(oidc); revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("after 1st refresh: got (%q, %q, %q), want overrides to hold", revocation, endSession, introspection)
}
secondRefresh := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
RevokeURL: "https://provider.example.com/refresh2-revoke",
EndSessionURL: "https://provider.example.com/refresh2-endsession",
IntrospectionURL: "https://provider.example.com/refresh2-introspect",
}
oidc.updateMetadataEndpoints(secondRefresh)
if revocation, endSession, introspection := issue152Endpoints(oidc); revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("after 2nd refresh: got (%q, %q, %q), want overrides to still hold", revocation, endSession, introspection)
}
}
// TestIssue152NewWithContextWiring proves NewWithContext wires
// Config.RevocationURL / OIDCEndSessionURL / IntrospectionURL onto the three
// unexported config* fields and cold-start-seeds the live fields from them
// (main.go, the configRevocationURL/configEndSessionURL/configIntrospectionURL
// and revocationURL/endSessionURL/introspectionURL initializers), both when
// discovery succeeds with different values (override must still win) and
// when discovery fails outright (the cold-start seed must not be lost).
func TestIssue152NewWithContextWiring(t *testing.T) {
overrideRevocation := "https://override.example.com/revoke"
overrideEndSession := "https://override.example.com/endsession"
overrideIntrospection := "https://override.example.com/introspect"
t.Run("discovery succeeds with different values, overrides still win", func(t *testing.T) {
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/.well-known/openid-configuration") {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(ProviderMetadata{
Issuer: srv.URL,
AuthURL: srv.URL + "/auth",
TokenURL: srv.URL + "/token",
JWKSURL: srv.URL + "/jwks",
RevokeURL: srv.URL + "/discovered-revoke",
EndSessionURL: srv.URL + "/discovered-endsession",
IntrospectionURL: srv.URL + "/discovered-introspect",
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
cfg := issue152BaseConfig()
cfg.ProviderURL = srv.URL
cfg.RevocationURL = overrideRevocation
cfg.OIDCEndSessionURL = overrideEndSession
cfg.IntrospectionURL = overrideIntrospection
oidc, err := NewWithContext(context.Background(), cfg, nil, "issue152-wiring-success")
if err != nil {
t.Fatalf("NewWithContext must accept a valid config with overrides, got: %v", err)
}
defer func() { _ = oidc.Close() }()
<-oidc.initComplete // wait for the discovery goroutine so field reads below are race-safe.
revocation, endSession, introspection := issue152Endpoints(oidc)
if revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("got (%q, %q, %q), want overrides (%q, %q, %q) to win over successful discovery",
revocation, endSession, introspection, overrideRevocation, overrideEndSession, overrideIntrospection)
}
})
t.Run("discovery fails outright, cold-start seed holds", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
cfg := issue152BaseConfig()
cfg.ProviderURL = srv.URL
cfg.RevocationURL = overrideRevocation
cfg.OIDCEndSessionURL = overrideEndSession
cfg.IntrospectionURL = overrideIntrospection
oidc, err := NewWithContext(context.Background(), cfg, nil, "issue152-wiring-coldstart")
if err != nil {
t.Fatalf("NewWithContext must accept a valid config even though discovery will fail, got: %v", err)
}
defer func() { _ = oidc.Close() }()
<-oidc.initComplete // discovery has failed and returned by now.
revocation, endSession, introspection := issue152Endpoints(oidc)
if revocation != overrideRevocation || endSession != overrideEndSession || introspection != overrideIntrospection {
t.Fatalf("got (%q, %q, %q), want the constructor's cold-start seed (%q, %q, %q) to survive a failed discovery",
revocation, endSession, introspection, overrideRevocation, overrideEndSession, overrideIntrospection)
}
})
}
// TestIssue152EndToEndIntrospection proves the override is honored one layer
// up from the field: with a discovery document that omits
// introspection_endpoint entirely, introspectToken (token_introspection.go)
// must still reach the manually-configured endpoint and return its result.
func TestIssue152EndToEndIntrospection(t *testing.T) {
var hits int32
var gotToken string
introspectionSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
if err := r.ParseForm(); err == nil {
gotToken = r.PostFormValue("token")
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"active": true})
}))
defer introspectionSrv.Close()
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
clientSecret: "test-secret",
httpClient: http.DefaultClient,
configIntrospectionURL: introspectionSrv.URL,
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
// IntrospectionURL intentionally omitted: the override is the only
// source of the endpoint.
}
oidc.updateMetadataEndpoints(metadata)
if _, _, introspection := issue152Endpoints(oidc); introspection != introspectionSrv.URL {
t.Fatalf("introspectionURL = %q, want override %q wired before calling introspectToken", introspection, introspectionSrv.URL)
}
resp, err := oidc.introspectToken("opaque-test-token")
if err != nil {
t.Fatalf("introspectToken must succeed against the manual override endpoint, got: %v", err)
}
if !resp.Active {
t.Fatal("introspectToken response Active = false, want true")
}
if got := atomic.LoadInt32(&hits); got != 1 {
t.Fatalf("introspection endpoint hit count = %d, want 1", got)
}
if gotToken != "opaque-test-token" {
t.Fatalf("introspection request token = %q, want %q", gotToken, "opaque-test-token")
}
}
// TestIssue152SiblingRevocationAndLogout proves the two dead siblings this
// issue repaired are wired end-to-end: RevokeTokenWithProvider
// (token_manager.go) posts to a configRevocationURL override, and the
// end-session URL BuildLogoutURL (helpers.go) builds from is the
// configEndSessionURL override. The full handleLogout HTTP handler needs a
// live SessionManager and an authenticated cookie to reach BuildLogoutURL,
// which is disproportionate for this fix; instead this asserts the live
// field wiring plus a direct BuildLogoutURL call with that wired value,
// which is the same function handleLogout calls.
func TestIssue152SiblingRevocationAndLogout(t *testing.T) {
t.Run("RevokeTokenWithProvider posts to the override", func(t *testing.T) {
var hits int32
var gotToken string
revocationSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
if err := r.ParseForm(); err == nil {
gotToken = r.PostFormValue("token")
}
w.WriteHeader(http.StatusOK)
}))
defer revocationSrv.Close()
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
clientSecret: "test-secret",
httpClient: http.DefaultClient,
configRevocationURL: revocationSrv.URL,
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
// RevokeURL intentionally omitted.
}
oidc.updateMetadataEndpoints(metadata)
if err := oidc.RevokeTokenWithProvider("opaque-test-token", "access_token"); err != nil {
t.Fatalf("RevokeTokenWithProvider must succeed against the manual override endpoint, got: %v", err)
}
if got := atomic.LoadInt32(&hits); got != 1 {
t.Fatalf("revocation endpoint hit count = %d, want 1", got)
}
if gotToken != "opaque-test-token" {
t.Fatalf("revocation request token = %q, want %q", gotToken, "opaque-test-token")
}
})
t.Run("logout URL builder emits the override", func(t *testing.T) {
overrideEndSession := "https://override.example.com/endsession"
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
configEndSessionURL: overrideEndSession,
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
// EndSessionURL intentionally omitted.
}
oidc.updateMetadataEndpoints(metadata)
_, endSession, _ := issue152Endpoints(oidc)
if endSession != overrideEndSession {
t.Fatalf("endSessionURL = %q, want override %q wired before building the logout URL", endSession, overrideEndSession)
}
logoutURL, err := BuildLogoutURL(endSession, "idtok123", "https://myapp.example.com/")
if err != nil {
t.Fatalf("BuildLogoutURL must succeed with the wired override, got: %v", err)
}
if !strings.HasPrefix(logoutURL, overrideEndSession+"?") {
t.Fatalf("logoutURL = %q, want prefix %q", logoutURL, overrideEndSession+"?")
}
if !strings.Contains(logoutURL, "id_token_hint=idtok123") {
t.Fatalf("logoutURL = %q, want it to contain the id_token_hint", logoutURL)
}
})
}
// TestIssue152ConfigValidation pins Config.Validate's IntrospectionURL check:
// empty is fine (feature is opt-in), a valid HTTPS URL is fine, plaintext
// HTTP is fine only for a loopback host (isValidSecureURL's RFC 8252-style
// allowance, mirrored from the ProviderURL/RevocationURL/OIDCEndSessionURL
// checks), and anything else — plaintext HTTP to a remote host, or a
// malformed URL — is rejected with the introspectionURL-specific message.
func TestIssue152ConfigValidation(t *testing.T) {
cases := []struct {
name string
value string
wantErr bool
errContains string
}{
{"valid HTTPS URL", "https://idp.example.com/introspect", false, ""},
{"empty is valid (opt-in feature)", "", false, ""},
{"plaintext HTTP allowed for loopback", "http://localhost:8080/introspect", false, ""},
{"plaintext HTTP rejected for remote host", "http://evil.example.com/x", true, "introspectionURL must be a valid HTTPS URL"},
{"malformed URL rejected", "://garbage", true, "introspectionURL must be a valid HTTPS URL"},
{"URL with no host rejected", "https:///nohost", true, "introspectionURL must be a valid HTTPS URL"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := issue152BaseConfig()
cfg.IntrospectionURL = tc.value
err := cfg.Validate()
if tc.wantErr {
if err == nil {
t.Fatalf("Validate() with IntrospectionURL=%q must fail", tc.value)
}
if !strings.Contains(err.Error(), tc.errContains) {
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tc.errContains)
}
return
}
if err != nil {
t.Fatalf("Validate() with IntrospectionURL=%q must succeed, got: %v", tc.value, err)
}
})
}
}
// TestIssue152InvalidIntrospectionURLNewContract proves an invalid
// IntrospectionURL is rejected by New() through the same fail-closed,
// untyped-nil-handler contract issue #151 established for every other
// Config.Validate failure: a concrete "invalid configuration" error, and h
// itself must be an untyped nil (not a typed-nil *TraefikOidc boxed into a
// non-nil http.Handler interface value).
func TestIssue152InvalidIntrospectionURLNewContract(t *testing.T) {
cfg := issue152BaseConfig()
cfg.IntrospectionURL = "http://evil.example.com/x" // plaintext HTTP to a remote host: rejected.
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
h, err := New(context.Background(), next, cfg, "issue152-invalid-introspection")
if err == nil {
t.Fatal("New() must reject an invalid IntrospectionURL")
}
if !strings.Contains(err.Error(), "invalid configuration") {
t.Fatalf("New() error = %q, want it to contain %q", err.Error(), "invalid configuration")
}
if h != nil {
t.Fatalf("New() must return an untyped nil handler on error, got %T", h)
}
}
+326
View File
@@ -0,0 +1,326 @@
package traefikoidc
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// issue154_regression_test.go — regression tests for issue #154.
//
// A providerURL pointing at a localhost identity provider (local
// development, sidecar OIDC providers) caused an infinite redirect loop.
// validateHost unconditionally rejected loopback/localhost hosts, so
// buildURLWithParams returned "" for the discovered authorization endpoint,
// buildAuthURL returned "", and Traefik kept redirecting to an empty
// Location header forever.
//
// The fix adds an unexported `allowLoopbackHosts bool` field to TraefikOidc,
// derived at construction time from `isLoopbackProviderURL(config.ProviderURL)`
// (settings.go). validateHost now gates loopback IPs and localhost/127.x/::1
// literals on `!t.allowLoopbackHosts`, while link-local, multicast, and the
// cloud-metadata hostnames stay unconditionally blocked (SSRF surface must
// not widen). updateMetadataEndpoints recomputes the same derivation from
// t.providerURL directly (not the struct field), so it stays correct for
// TraefikOidc values test code builds as struct literals.
//
// These tests pin: the opt-in behaves correctly, the default (flag unset)
// still blocks loopback exactly as before the fix, the SSRF-relevant hosts
// stay blocked even with the opt-in active, allowPrivateIPAddresses remains
// an independent gate, the derivation helper is correct in isolation, the
// end-to-end buildAuthURL behavior for both flag states, that NewWithContext
// derives the field correctly from config.ProviderURL, and that the
// discovery layer (updateMetadataEndpoints) and the URL-validation layer
// (validateHost/buildAuthURL) agree on a loopback provider.
// issue154LoopbackHosts are host strings that represent localhost/loopback
// under every literal form validateHost must recognize: bare hostname,
// hostname:port, case variation, IPv4 loopback with and without port, an
// IPv6 loopback literal with port, and a non-canonical loopback IP
// (127.0.0.0/8 is entirely loopback, not just 127.0.0.1).
var issue154LoopbackHosts = []string{
"localhost",
"localhost:8080",
"LOCALHOST",
"127.0.0.1",
"127.0.0.1:8080",
"[::1]:8080",
"127.0.0.2:9000",
}
// TestIssue154ValidateHostLoopbackAllowed pins the opt-in: once a middleware
// instance has allowLoopbackHosts=true (as New/NewWithContext derive it from
// a loopback providerURL), every loopback host form validates cleanly.
func TestIssue154ValidateHostLoopbackAllowed(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.allowLoopbackHosts = true
for _, host := range issue154LoopbackHosts {
t.Run(host, func(t *testing.T) {
if err := middleware.validateHost(host); err != nil {
t.Fatalf("validateHost(%q) with allowLoopbackHosts=true must succeed, got: %v", host, err)
}
})
}
}
// TestIssue154ValidateHostLoopbackDefaultBlocked pins the default-preserving
// half of the fix: a middleware instance built without explicitly setting
// allowLoopbackHosts (its zero value, matching every TraefikOidc a
// non-loopback New()/NewWithContext call produces) must still reject every
// loopback host form, exactly as before the fix. This is asserted as
// "error present", not against exact error text: "[::1]:8080" alone
// (without a bracket-less counterpart) does not hit the pre-existing
// net.SplitHostPort quirk where a bare "[::1]" without a port fails with
// "invalid host format" instead of the loopback-specific message — but
// pinning only non-nil keeps this test robust to either error path.
func TestIssue154ValidateHostLoopbackDefaultBlocked(t *testing.T) {
middleware := createMinimalMiddleware() // allowLoopbackHosts left at zero value (false)
for _, host := range issue154LoopbackHosts {
t.Run(host, func(t *testing.T) {
if err := middleware.validateHost(host); err == nil {
t.Fatalf("validateHost(%q) with default allowLoopbackHosts must still be rejected", host)
}
})
}
}
// TestIssue154SecurityEnvelopeWithLoopbackAllowed is the highest-value test
// in this file: it proves that opting into loopback hosts does not widen the
// SSRF surface. Cloud metadata addresses, link-local, multicast, and
// unspecified addresses must stay blocked even with allowLoopbackHosts=true.
func TestIssue154SecurityEnvelopeWithLoopbackAllowed(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.allowLoopbackHosts = true
cases := []struct {
name string
host string
wantSubstr string
}{
{"AWS/GCP metadata IP", "169.254.169.254", "loopback/link-local"},
{"AWS/GCP metadata IP with port", "169.254.169.254:80", "loopback/link-local"},
{"GCP metadata hostname", "metadata.google.internal", "dangerous hostname"},
{"GCP metadata hostname with port", "metadata.google.internal:80", "dangerous hostname"},
{"IPv6 link-local", "[fe80::1]:80", "loopback/link-local"},
{"IPv4 link-local multicast", "224.0.0.1", "loopback/link-local"},
{"unspecified IPv4", "0.0.0.0", "unspecified or multicast"},
{"unspecified IPv6 with port", "[::]:80", "unspecified or multicast"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := middleware.validateHost(tc.host)
if err == nil {
t.Fatalf("validateHost(%q) must still be rejected with allowLoopbackHosts=true", tc.host)
}
if !strings.Contains(err.Error(), tc.wantSubstr) {
t.Fatalf("validateHost(%q) error = %q, want substring %q", tc.host, err.Error(), tc.wantSubstr)
}
})
}
}
// TestIssue154PrivateIPIndependence proves allowLoopbackHosts and
// allowPrivateIPAddresses are independent gates: opting a middleware into
// loopback hosts must not also open up RFC 1918 private ranges.
func TestIssue154PrivateIPIndependence(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.allowLoopbackHosts = true
middleware.allowPrivateIPAddresses = false
privateHosts := []string{"10.0.0.5", "192.168.1.10", "172.16.0.1"}
for _, host := range privateHosts {
t.Run(host, func(t *testing.T) {
err := middleware.validateHost(host)
if err == nil {
t.Fatalf("validateHost(%q) must be rejected when allowPrivateIPAddresses=false", host)
}
if !strings.Contains(err.Error(), "private/internal") {
t.Fatalf("validateHost(%q) error = %q, want substring %q", host, err.Error(), "private/internal")
}
})
}
}
// TestIssue154IsLoopbackProviderURL exercises the construction-time
// derivation helper in isolation, including malformed input that must not
// panic.
func TestIssue154IsLoopbackProviderURL(t *testing.T) {
cases := []struct {
name string
providerURL string
want bool
}{
{"http localhost with port", "http://localhost:8080", true},
{"http loopback IPv4 with port", "http://127.0.0.1:9000", true},
{"http loopback IPv6 with port", "http://[::1]:9000", true},
{"https localhost uppercase", "https://LOCALHOST:9000", true},
{"https real remote provider", "https://accounts.google.com", false},
{"https another remote provider", "https://idp.example.com", false},
{"empty string", "", false},
{"malformed URL must not panic", "://garbage", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := isLoopbackProviderURL(tc.providerURL)
if got != tc.want {
t.Fatalf("isLoopbackProviderURL(%q) = %v, want %v", tc.providerURL, got, tc.want)
}
})
}
}
// TestIssue154BuildAuthURLLoopbackProvider is the issue scenario reproduced
// at the unit level: a loopback authURL must produce a real redirect URL
// when allowLoopbackHosts is set, and the pre-fix behavior (empty string,
// the infinite-redirect-loop trigger) must be pinned as the default when it
// is not.
func TestIssue154BuildAuthURLLoopbackProvider(t *testing.T) {
t.Run("allowLoopbackHosts=true builds a real auth URL", func(t *testing.T) {
middleware := createMinimalMiddleware()
middleware.allowLoopbackHosts = true
middleware.authURL = "http://localhost:8080/oauth/authorize"
middleware.issuerURL = "http://localhost:8080"
got := middleware.buildAuthURL("http://localhost:5555/callback", "state123", "nonce456", "")
if got == "" {
t.Fatal("buildAuthURL returned empty string for a loopback provider with allowLoopbackHosts=true")
}
if !strings.HasPrefix(got, "http://localhost:8080/oauth/authorize?") {
t.Fatalf("buildAuthURL = %q, want prefix %q", got, "http://localhost:8080/oauth/authorize?")
}
for _, want := range []string{"client_id=", "state=state123", "nonce=nonce456"} {
if !strings.Contains(got, want) {
t.Fatalf("buildAuthURL = %q, want it to contain %q", got, want)
}
}
})
t.Run("default allowLoopbackHosts=false pins pre-fix empty-string behavior", func(t *testing.T) {
middleware := createMinimalMiddleware() // allowLoopbackHosts left at zero value (false)
middleware.authURL = "http://localhost:8080/oauth/authorize"
middleware.issuerURL = "http://localhost:8080"
got := middleware.buildAuthURL("http://localhost:5555/callback", "state123", "nonce456", "")
if got != "" {
t.Fatalf("buildAuthURL = %q, want empty string when a loopback provider is not opted in", got)
}
})
}
// issue154BaseConfig returns a complete, valid Config with the given
// providerURL, following the minimal-valid-config pattern used elsewhere in
// this package (e.g. TestRank2And6_InvalidConfigFailsClosed in
// security_audit_fixes_test.go).
func issue154BaseConfig(providerURL string) *Config {
return &Config{
ProviderURL: providerURL,
ClientID: "test-client",
ClientSecret: "test-secret",
CallbackURL: "/callback",
SessionEncryptionKey: "this-is-a-valid-session-key-32b!",
RateLimit: 100,
}
}
// TestIssue154NewWithContextDerivation proves NewWithContext derives
// allowLoopbackHosts from config.ProviderURL at construction time (main.go,
// the `allowLoopbackHosts: isLoopbackProviderURL(config.ProviderURL)` field
// initializer). The field is set synchronously before the metadata-discovery
// goroutine starts, so this needs no live discovery endpoint: the ports used
// below (a loopback port nothing listens on, and an RFC 2606 .invalid host)
// only need to fail fast once the background goroutine attempts discovery —
// Close() cancels that goroutine's context regardless of outcome.
func TestIssue154NewWithContextDerivation(t *testing.T) {
t.Run("loopback providerURL derives allowLoopbackHosts=true", func(t *testing.T) {
// A real httptest server (loopback by construction) so the
// background discovery goroutine NewWithContext starts completes
// quickly and Close() below does not have to wait out its 10s
// goroutine-shutdown timeout for an unreachable host. Mirrors
// TestInitializeMetadata's server setup in main_initialization_test.go.
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/.well-known/openid-configuration") {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(ProviderMetadata{
Issuer: srv.URL,
AuthURL: srv.URL + "/auth",
TokenURL: srv.URL + "/token",
JWKSURL: srv.URL + "/jwks",
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
cfg := issue154BaseConfig(srv.URL)
oidc, err := NewWithContext(context.Background(), cfg, nil, "issue154-loopback")
if err != nil {
t.Fatalf("NewWithContext must accept a valid loopback config, got: %v", err)
}
defer func() { _ = oidc.Close() }()
if !oidc.allowLoopbackHosts {
t.Fatal("expected allowLoopbackHosts=true for a loopback providerURL")
}
})
t.Run("non-loopback providerURL derives allowLoopbackHosts=false", func(t *testing.T) {
cfg := issue154BaseConfig("https://provider.invalid")
oidc, err := NewWithContext(context.Background(), cfg, nil, "issue154-remote")
if err != nil {
t.Fatalf("NewWithContext must accept a valid remote config, got: %v", err)
}
defer func() { _ = oidc.Close() }()
if oidc.allowLoopbackHosts {
t.Fatal("expected allowLoopbackHosts=false for a non-loopback providerURL")
}
})
}
// TestIssue154DiscoveryAndURLValidationAgree catches layer drift between the
// discovery-endpoint sanitizer (updateMetadataEndpoints, which recomputes
// isLoopbackProviderURL(t.providerURL) itself per its doc comment, since
// test helpers build &TraefikOidc{} literals without going through
// NewWithContext) and the outbound URL validator (validateHost, gated on the
// allowLoopbackHosts field). Both layers must agree that a loopback provider
// is usable end-to-end: discovery must accept the loopback authURL, and
// buildAuthURL must build a real URL once allowLoopbackHosts is set to
// mirror what NewWithContext would have derived.
func TestIssue154DiscoveryAndURLValidationAgree(t *testing.T) {
oidc := &TraefikOidc{
providerURL: "http://localhost:8080",
logger: NewLogger("debug"),
clientID: "test-client",
}
metadata := &ProviderMetadata{
Issuer: "http://localhost:8080",
AuthURL: "http://localhost:8080/oauth/authorize",
TokenURL: "http://localhost:8080/oauth/token",
JWKSURL: "http://localhost:8080/jwks",
}
oidc.updateMetadataEndpoints(metadata)
if oidc.authURL == "" {
t.Fatal("updateMetadataEndpoints rejected a loopback authURL for a loopback providerURL (discovery layer drift)")
}
// The URL-validation layer must independently agree once
// allowLoopbackHosts mirrors what NewWithContext would have derived from
// this same loopback providerURL.
oidc.allowLoopbackHosts = true
if got := oidc.buildAuthURL("http://localhost:5555/callback", "state", "nonce", ""); got == "" {
t.Fatal("buildAuthURL returned empty for the discovered loopback authURL with allowLoopbackHosts=true (validation layer drift)")
}
}
+88 -22
View File
@@ -9,7 +9,6 @@ import (
"encoding/hex"
"fmt"
"net/http"
"net/url"
"os"
"runtime"
"strings"
@@ -83,6 +82,39 @@ var defaultExcludedURLs = map[string]struct{}{
"/favicon": {},
}
// headerTemplateFuncMap returns the function map available to custom header
// value templates, gated by the effective claims whitelist (built-in +
// config.AllowedClaims). It exposes exactly two helpers:
// - default: substitute a fallback when a value is nil/empty.
// - get: safe map access RESTRICTED to whitelisted claim keys, so it cannot be
// used to read a non-whitelisted claim, a raw token, or the whole data map
// (issue #149 review). This is the sole runtime enforcement of the claims
// whitelist for `get`; static validation cannot reliably parse its arguments.
func headerTemplateFuncMap(allowedClaims map[string]bool) template.FuncMap {
if allowedClaims == nil {
allowedClaims = safeClaimsFields
}
return template.FuncMap{
"default": func(defaultVal interface{}, val interface{}) interface{} {
if val == nil || val == "" {
return defaultVal
}
return val
},
"get": func(m interface{}, key string) interface{} {
if !allowedClaims[key] {
return ""
}
if mapVal, ok := m.(map[string]interface{}); ok {
if val, exists := mapVal[key]; exists {
return val
}
}
return ""
},
}
}
// New creates a new TraefikOidc middleware instance.
// It initializes all components including caches, HTTP clients, session management,
// templates, and starts background processes for metadata discovery.
@@ -103,7 +135,20 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
telemetry.Send("traefikoidc", traefikoidcPluginVersion)
}
})
return NewWithContext(ctx, config, next, name)
// Deliberately NOT `return NewWithContext(...)` (issue #151): that tail call
// converts (*TraefikOidc, error) to (http.Handler, error) in the return
// statement. Compiled, a failed construction then returns a typed-nil
// *TraefikOidc wrapped in a NON-nil http.Handler; under yaegi v0.16.1 (the
// interpreter Traefik and the plugin-catalog analyzer embed) the same
// statement zeroes BOTH results on the error path, so callers saw
// (nil, nil) and reported the opaque "invalid handler type: <nil>" instead
// of the real error. The explicit split returns an untyped nil handler and
// keeps the error intact in both worlds.
t, err := NewWithContext(ctx, config, next, name)
if err != nil {
return nil, err
}
return t, nil
}
// NewWithContext creates a new TraefikOidc middleware instance with proper context handling.
@@ -124,6 +169,11 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
// any session. Traefik's yaegi plugin analyzer supplies a valid key via
// .traefik.yml testData, so it passes; only misconfigured deployments fail.
if err := config.Validate(); err != nil {
// Surface the concrete reason. When New() returns a nil handler, Traefik
// only logs the opaque router-level "invalid handler type: <nil>" and
// swallows this error, leaving operators no way to see WHY the plugin
// failed to build (issue #149). Log it explicitly at construction.
logger.Errorf("traefikoidc: invalid configuration, middleware not built: %v", err)
return nil, fmt.Errorf("invalid configuration: %w", err)
}
// Setup HTTP client
@@ -221,6 +271,21 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
allowOpaqueTokens: config.AllowOpaqueTokens,
requireTokenIntrospection: config.RequireTokenIntrospection,
disableReplayDetection: config.DisableReplayDetection,
// Operator-configured endpoint overrides (issue #152). Applied inside
// updateMetadataEndpoints so the override survives every metadata
// refresh, not just construction.
configRevocationURL: config.RevocationURL,
configEndSessionURL: config.OIDCEndSessionURL,
configIntrospectionURL: config.IntrospectionURL,
// Cold-start seed: discovery failure must not lose operator-configured
// endpoints; updateMetadataEndpoints re-applies these on every refresh.
// Seeding introspectionURL here does not populate metadataSnapshot
// (only updateMetadataEndpoints does); all three consumers
// (token_introspection.go, token_manager.go, helpers.go) read these
// metadataMu-guarded fields directly, not the snapshot.
revocationURL: config.RevocationURL,
endSessionURL: config.OIDCEndSessionURL,
introspectionURL: config.IntrospectionURL,
scopes: func() []string {
userProvidedScopes := deduplicateScopes(config.Scopes)
@@ -263,6 +328,7 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
scopeFilter: NewScopeFilter(logger), // NEW - for discovery-based scope filtering
dcrConfig: config.DynamicClientRegistration,
allowPrivateIPAddresses: config.AllowPrivateIPAddresses,
allowLoopbackHosts: isLoopbackProviderURL(config.ProviderURL),
minimalHeaders: config.MinimalHeaders,
stripAuthCookies: config.StripAuthCookies,
enableBackchannelLogout: config.EnableBackchannelLogout,
@@ -388,22 +454,7 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
t.headerTemplates = make(map[string]*template.Template)
funcMap := template.FuncMap{
"default": func(defaultVal interface{}, val interface{}) interface{} {
if val == nil || val == "" {
return defaultVal
}
return val
},
"get": func(m interface{}, key string) interface{} {
if mapVal, ok := m.(map[string]interface{}); ok {
if val, exists := mapVal[key]; exists {
return val
}
}
return ""
},
}
funcMap := headerTemplateFuncMap(claimsWhitelist(config.AllowedClaims))
for _, header := range config.Headers {
tmpl := template.New(header.Name).Funcs(funcMap).Option("missingkey=zero")
@@ -526,10 +577,11 @@ func (t *TraefikOidc) updateMetadataEndpoints(metadata *ProviderMetadata) {
// discovered endpoint pointed at a blocked address before the plugin issues
// outbound requests to it, so it can never be used to reach the cloud
// metadata service or an internal host.
allowLoopback := false
if pu, err := url.Parse(t.providerURL); err == nil {
allowLoopback = isLoopbackHost(pu.Hostname())
}
// Recomputed from t.providerURL (not the allowLoopbackHosts field) so this
// stays correct for TraefikOidc values built outside New() (test helpers
// construct &TraefikOidc{providerURL: ...} directly, leaving
// allowLoopbackHosts at its zero value).
allowLoopback := isLoopbackProviderURL(t.providerURL)
sanitize := func(name, raw string) string {
if err := t.validateDiscoveredEndpoint(raw, allowLoopback); err != nil {
t.logger.Errorf("Ignoring discovered %s endpoint %q: %v", name, raw, err)
@@ -565,6 +617,20 @@ func (t *TraefikOidc) updateMetadataEndpoints(metadata *ProviderMetadata) {
t.logger.Errorf("Ignoring discovered issuer %q: host does not match configured providerURL", discoveredIssuer)
discoveredIssuer = ""
}
// Operator-configured endpoint overrides take precedence over discovered
// values. They deliberately skip validateDiscoveredEndpoint and the
// sameHost pin: those defend against a poisoned discovery document, while
// operator config sits at the same trust tier as providerURL itself and is
// already gated by isValidSecureURL at config time.
if t.configRevocationURL != "" {
metadata.RevokeURL = t.configRevocationURL
}
if t.configEndSessionURL != "" {
metadata.EndSessionURL = t.configEndSessionURL
}
if t.configIntrospectionURL != "" {
metadata.IntrospectionURL = t.configIntrospectionURL
}
t.metadataMu.Lock()
+1
View File
@@ -796,6 +796,7 @@ func (t *TraefikOidc) forwardAuthorized(rw http.ResponseWriter, req *http.Reques
templateData := map[string]interface{}{
"AccessToken": p.AccessToken,
"IDToken": p.IDToken,
"IdToken": p.IDToken, // documented spelling (README/CONFIGURATION); alias so {{.IdToken}} renders (issue #149 review)
"RefreshToken": p.RefreshToken,
"Claims": p.Claims,
}
+48 -1
View File
@@ -18,6 +18,50 @@ func TestIssueRegressions(t *testing.T) {
t.Run("Issue60_Missing_Claim_Fields", testIssue60MissingClaimFields)
t.Run("Issue60_Safe_Template_Functions", testIssue60SafeTemplateFunctions)
t.Run("Issue60_Double_Processing_Concern", testIssue60DoubleProcessing)
t.Run("Issue148_Template_Whitespace_Tolerance", testIssue148TemplateWhitespace)
}
// testIssue148TemplateWhitespace tests that header template validation tolerates
// whitespace inside Go template delimiters (e.g. "{{ get" with a leading space),
// which Go's text/template treats identically to "{{get". v1.0.26 rejected the
// spaced form, causing "invalid handler type: <nil>" at startup. See issue #148.
func testIssue148TemplateWhitespace(t *testing.T) {
baseConfig := func() *traefikoidc.Config {
c := traefikoidc.CreateConfig()
c.ProviderURL = "https://example.com"
c.ClientID = "test-client"
c.ClientSecret = "test-secret"
c.CallbackURL = "/callback"
c.SessionEncryptionKey = "test-encryption-key-32-characters"
return c
}
// Spaced delimiters must validate identically to the compact form.
validSpaced := []traefikoidc.TemplatedHeader{
{Name: "X-Get-Spaced", Value: "{{ get .Claims \"email\" }}"},
{Name: "X-Get-MultiSpace", Value: "{{ get .Claims \"email\"}}"},
{Name: "X-Default-Spaced", Value: "{{ default \"unknown\" .Claims.department }}"},
{Name: "X-Claims-Spaced", Value: "{{ .Claims.email }}"},
{Name: "X-AccessToken-Spaced", Value: "{{ .AccessToken }}"},
}
for _, header := range validSpaced {
config := baseConfig()
config.Headers = []traefikoidc.TemplatedHeader{header}
assert.NoError(t, config.Validate(),
"Spaced template should validate: %s", header.Value)
}
// A leading space must NOT smuggle a dangerous pattern past validation.
dangerousSpaced := []traefikoidc.TemplatedHeader{
{Name: "X-Bad-Range", Value: "{{ range .Items }}{{.}}{{end}}"},
{Name: "X-Bad-Call", Value: "{{ call .Func }}"},
}
for _, header := range dangerousSpaced {
config := baseConfig()
config.Headers = []traefikoidc.TemplatedHeader{header}
assert.Error(t, config.Validate(),
"Spaced dangerous template must still be rejected: %s", header.Value)
}
}
// testIssue53CSRFRegression tests the specific issue reported in GitHub issue #53
@@ -325,7 +369,10 @@ func testIssue60SafeTemplateFunctions(t *testing.T) {
config.Headers = []traefikoidc.TemplatedHeader{header}
err := config.Validate()
require.Error(t, err, "Dangerous template should be rejected: %s", header.Value)
assert.Contains(t, err.Error(), "dangerous", "Error should mention dangerous pattern")
// The template validator (template_validation.go) rejects these; Config.Validate
// wraps the reason with this stable prefix. Assert the behavior (rejected by
// the security validator) rather than a specific message word.
assert.Contains(t, err.Error(), "failed security validation", "should be rejected by the template security validator")
}
// Test all safe patterns from the documentation
+93 -193
View File
@@ -49,14 +49,24 @@ type Config struct {
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"`
// IntrospectionURL overrides the RFC 7662 token introspection endpoint.
// Set it when the IdP omits introspection_endpoint from discovery. It
// takes precedence over the discovered value.
IntrospectionURL string `json:"introspectionURL"`
Scopes []string `json:"scopes"`
AllowedRolesAndGroups []string `json:"allowedRolesAndGroups"`
ExcludedURLs []string `json:"excludedURLs"`
AllowedUserDomains []string `json:"allowedUserDomains"`
AllowedUsers []string `json:"allowedUsers"`
Headers []TemplatedHeader `json:"headers"`
// AllowedClaims extends the built-in claims whitelist that header value
// templates may emit. Add the exact claim name (e.g. "employee_id") to allow
// {{.Claims.employee_id}} / {{get .Claims "employee_id"}}. Applies to both
// template validation and the runtime get helper. Built-in claims (email,
// groups, roles, realm_access, ...) need not be listed.
AllowedClaims []string `json:"allowedClaims,omitempty"`
ExtraAuthParams map[string]string `json:"extraAuthParams,omitempty"`
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
@@ -477,6 +487,10 @@ func (c *Config) Validate() error {
if c.OIDCEndSessionURL != "" && !isValidSecureURL(c.OIDCEndSessionURL) {
return fmt.Errorf("oidcEndSessionURL must be a valid HTTPS URL")
}
// Validate introspection URL if set
if c.IntrospectionURL != "" && !isValidSecureURL(c.IntrospectionURL) {
return fmt.Errorf("introspectionURL must be a valid HTTPS URL")
}
// Validate post-logout redirect URI if set
if c.PostLogoutRedirectURI != "" && c.PostLogoutRedirectURI != "/" {
@@ -530,7 +544,9 @@ func (c *Config) Validate() error {
}
}
// Validate headers configuration for template security
// 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")
@@ -538,8 +554,20 @@ func (c *Config) Validate() error {
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)
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
@@ -557,7 +585,7 @@ func (c *Config) Validate() error {
}
// Validate template syntax and security
if err := validateTemplateSecure(header.Value); err != nil {
if err := validateTemplateSecure(header.Value, allowedClaims); err != nil {
return fmt.Errorf("header template '%s' failed security validation: %w", header.Value, err)
}
}
@@ -565,190 +593,50 @@ func (c *Config) Validate() error {
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.
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
}
// 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.
@@ -791,6 +679,18 @@ func isLoopbackHost(host string) bool {
return false
}
// isLoopbackProviderURL reports whether providerURL's host is loopback
// (localhost or a loopback IP literal). Used at construction time to derive
// allowLoopbackHosts, so local-development providers are not blocked by
// validateHost's outbound URL checks.
func isLoopbackProviderURL(providerURL string) bool {
pu, err := url.Parse(providerURL)
if err != nil {
return false
}
return isLoopbackHost(pu.Hostname())
}
// isValidLogLevel checks if the provided log level string is one of the supported values ("debug", "info", "error").
//
// Parameters:
+362
View File
@@ -0,0 +1,362 @@
package traefikoidc
import (
"fmt"
"strings"
"text/template/parse"
)
// allowedRootTokenFields are the non-Claims values a header template may render
// directly from the root data context. Both ID-token spellings are accepted
// (templateData carries IDToken and IdToken).
var allowedRootTokenFields = map[string]bool{
"AccessToken": true,
"IdToken": true,
"IDToken": true,
"RefreshToken": true,
}
// validateTemplateParseFuncs is the function set the parser is told about. Only
// get/default exist; every other identifier used as a command (printf, call,
// index, js, ...) then fails to parse and is rejected — no blocklist needed.
var validateTemplateParseFuncs = map[string]any{
"get": struct{}{},
"default": struct{}{},
}
// claimsWhitelist returns the effective set of claim field names a header
// template may emit: the built-in safeClaimsFields plus any operator-configured
// extras (config.AllowedClaims). Blank/whitespace extras are ignored. The result
// gates BOTH static validation and the runtime get helper so the two never drift.
func claimsWhitelist(extra []string) map[string]bool {
m := make(map[string]bool, len(safeClaimsFields)+len(extra))
for k := range safeClaimsFields {
m[k] = true
}
for _, c := range extra {
if c = strings.TrimSpace(c); c != "" {
m[c] = true
}
}
return m
}
// templateValidator walks a parsed header template against a specific claims
// whitelist (built-in + operator-configured). Holding the set on the walker
// keeps it out of every method signature.
type templateValidator struct {
allowed map[string]bool
}
// validateTemplateSecure parses a header value template and walks its AST,
// permitting ONLY: literal text; the whitelisted root token fields; whitelisted
// .Claims.<field> access; range/with over a whitelisted .Claims.<field>; the
// safe get/default helpers; and {{if}} control flow. Everything else — bare
// {{.}}/{{$}} (whole-root dump), bare {{.Claims}} (whole-map dump), non-
// whitelisted claims, unknown functions, define/template/block — is rejected.
//
// allowed is the effective claim whitelist (see claimsWhitelist); a nil set
// falls back to the built-in safeClaimsFields.
//
// Parsing (not substring matching) is what makes this sound: the parser resolves
// whitespace, comments and the meaning of "." per scope, so obfuscation and the
// "whitelisted decoy + bare-map render" bypass class cannot slip through.
func validateTemplateSecure(templateStr string, allowed map[string]bool) error {
if allowed == nil {
allowed = safeClaimsFields
}
trees, err := parse.Parse("headerTemplate", templateStr, "{{", "}}", validateTemplateParseFuncs)
if err != nil {
return fmt.Errorf("invalid template: %w", err)
}
// {{define}}/{{block}} produce additional named trees; a header value template
// must be a single anonymous template, so reject anything that defines more.
if len(trees) != 1 {
return fmt.Errorf("template definitions ({{define}}/{{block}}) are not allowed")
}
tree, ok := trees["headerTemplate"]
if !ok || tree.Root == nil {
return fmt.Errorf("invalid template: empty parse tree")
}
v := &templateValidator{allowed: allowed}
// dotIsRoot=true: "." is the whole data context at the top level. Inside a
// range/with over a claims field it rebinds to that (already-whitelisted)
// element, so relative access there is safe.
return v.walkNode(tree.Root, true)
}
// walkNode validates a node and its children. dotIsRoot reports whether "."
// currently refers to the root data context (true) or to a whitelisted claim
// element rebound by an enclosing range/with (false).
func (v *templateValidator) walkNode(n parse.Node, dotIsRoot bool) error {
switch node := n.(type) {
case nil:
return nil
case *parse.ListNode:
if node == nil {
return nil
}
for _, child := range node.Nodes {
if err := v.walkNode(child, dotIsRoot); err != nil {
return err
}
}
return nil
case *parse.TextNode:
return nil
case *parse.ActionNode:
return v.validatePipe(node.Pipe, dotIsRoot)
case *parse.IfNode:
return v.walkBranch(node.Pipe, node.List, node.ElseList, dotIsRoot, dotIsRoot)
case *parse.WithNode:
// with rebinds "." to the target inside its body; the target must be a
// whitelisted claim, so the body runs with dotIsRoot=false.
if err := v.validateClaimSelector(node.Pipe, dotIsRoot, "with"); err != nil {
return err
}
return v.walkBranch(nil, node.List, node.ElseList, false, dotIsRoot)
case *parse.RangeNode:
// range iterates the target and rebinds "." to each element; the target
// must be a whitelisted claim collection, so the body is dotIsRoot=false.
if err := v.validateClaimSelector(node.Pipe, dotIsRoot, "range"); err != nil {
return err
}
return v.walkBranch(nil, node.List, node.ElseList, false, dotIsRoot)
case *parse.TemplateNode:
return fmt.Errorf("template inclusion is not allowed")
default:
return fmt.Errorf("unsupported template construct: %s", n.String())
}
}
// walkBranch validates an if/with/range: an optional condition pipe (evaluated
// in the outer scope), the body (bodyDotIsRoot), and the else branch (outer
// scope). For with/range the condition pipe is validated by the caller and
// condPipe is nil here.
func (v *templateValidator) walkBranch(condPipe *parse.PipeNode, body, elseList *parse.ListNode, bodyDotIsRoot, outerDotIsRoot bool) error {
if condPipe != nil {
if err := v.validatePipe(condPipe, outerDotIsRoot); err != nil {
return err
}
}
if err := v.walkNode(body, bodyDotIsRoot); err != nil {
return err
}
if elseList != nil {
if err := v.walkNode(elseList, outerDotIsRoot); err != nil {
return err
}
}
return nil
}
// validatePipe validates every command in a pipeline, including the right-hand
// side of any variable declaration ($x := ...). Declared vars therefore always
// hold already-validated values, so later references to them are safe.
func (v *templateValidator) validatePipe(pipe *parse.PipeNode, dotIsRoot bool) error {
if pipe == nil {
return nil
}
for _, cmd := range pipe.Cmds {
if err := v.validateCommand(cmd, dotIsRoot); err != nil {
return err
}
}
return nil
}
// validateCommand validates a single command (one stage of a pipeline). The
// first argument may be the get/default function; the rest, and every argument
// of a non-function command, must be a safe value expression.
func (v *templateValidator) validateCommand(cmd *parse.CommandNode, dotIsRoot bool) error {
if cmd == nil || len(cmd.Args) == 0 {
return nil
}
if id, ok := cmd.Args[0].(*parse.IdentifierNode); ok {
return v.validateFunctionCall(id.Ident, cmd.Args[1:], dotIsRoot)
}
for _, arg := range cmd.Args {
if err := v.validateValue(arg, dotIsRoot); err != nil {
return err
}
}
return nil
}
// validateFunctionCall allows only get/default. get's map argument must be a
// claims reference (never the root) and default's arguments are validated as
// values; the runtime get additionally enforces the key whitelist.
func (v *templateValidator) validateFunctionCall(name string, args []parse.Node, dotIsRoot bool) error {
switch name {
case "default":
for _, arg := range args {
if err := v.validateValue(arg, dotIsRoot); err != nil {
return err
}
}
return nil
case "get":
if len(args) < 1 {
return fmt.Errorf("get requires a map argument")
}
// The map argument must reference .Claims, never the root map (which
// would expose tokens / the whole Claims map). Remaining args (the key,
// possibly a pipeline) are validated as values.
if err := v.validateClaimsReference(args[0], dotIsRoot); err != nil {
return fmt.Errorf("get is only allowed on .Claims: %w", err)
}
// A literal key must be whitelisted (matches direct .Claims.<field>
// strictness); a dynamic key is enforced by the runtime get.
if len(args) >= 2 {
if key, ok := args[1].(*parse.StringNode); ok && !v.allowed[key.Text] {
return fmt.Errorf("access to Claims.%s is not allowed for security reasons", key.Text)
}
}
for _, arg := range args[1:] {
if err := v.validateValue(arg, dotIsRoot); err != nil {
return err
}
}
return nil
default:
return fmt.Errorf("function %q is not allowed in header templates", name)
}
}
// validateValue validates an expression that may be rendered or passed as an
// argument. It rejects anything resolving to the whole root or the whole Claims
// map, and any non-whitelisted claim.
func (v *templateValidator) validateValue(node parse.Node, dotIsRoot bool) error {
switch val := node.(type) {
case *parse.StringNode, *parse.NumberNode, *parse.BoolNode, *parse.NilNode:
return nil
case *parse.DotNode:
// "." is the whole root at the top level (dumps tokens + all claims); it
// is a single already-whitelisted claim element inside a range/with body.
if dotIsRoot {
return fmt.Errorf("rendering the whole data context ({{.}}) is not allowed")
}
return nil
case *parse.FieldNode:
return v.validateFieldAccess(val.Ident, dotIsRoot)
case *parse.VariableNode:
return v.validateVariableAccess(val.Ident)
case *parse.PipeNode:
return v.validatePipe(val, dotIsRoot)
case *parse.ChainNode:
// (base).Field.Field — the field chain only narrows access, so validating
// the base value is sufficient.
return v.validateValue(val.Node, dotIsRoot)
default:
return fmt.Errorf("unsupported template expression: %s", node.String())
}
}
// validateFieldAccess validates a dot-relative field access ".A.B.C" given
// whether "." is the root. In an element scope (dotIsRoot=false) the element is
// already a whitelisted claim, so any sub-field access is safe.
func (v *templateValidator) validateFieldAccess(idents []string, dotIsRoot bool) error {
if !dotIsRoot {
return nil
}
if len(idents) == 0 {
return fmt.Errorf("rendering the whole data context is not allowed")
}
root := idents[0]
if allowedRootTokenFields[root] {
return nil
}
if root == "Claims" {
if len(idents) < 2 {
return fmt.Errorf("rendering the whole Claims map ({{.Claims}}) is not allowed")
}
if !v.allowed[idents[1]] {
return fmt.Errorf("access to Claims.%s is not allowed for security reasons", idents[1])
}
// By design, only the top-level claim is whitelisted; deeper subfields
// (idents[2:]) of a map-valued whitelisted claim such as realm_access /
// resource_access are forwarded as-is. Whitelisting a map claim means
// opting into its nested contents, and the documented Keycloak pattern
// {{with .Claims.realm_access}}{{.roles}}{{end}} relies on it; nested
// provider keys can't be enumerated to whitelist individually.
return nil
}
return fmt.Errorf("access to .%s is not allowed in header templates", root)
}
// validateVariableAccess validates a $-variable reference. The bare root ("$"
// and "$.x") is treated as a root field access; a named range/with/declaration
// variable holds an already-validated value, so it and its sub-fields are safe.
func (v *templateValidator) validateVariableAccess(idents []string) error {
if len(idents) == 0 {
return nil
}
if idents[0] == "$" {
// "$" is the root: "$" alone dumps everything; "$.Claims.email" is a
// root-absolute field access validated like ".Claims.email".
if len(idents) == 1 {
return fmt.Errorf("rendering the whole data context ({{$}}) is not allowed")
}
return v.validateFieldAccess(idents[1:], true)
}
// Named variable ($e, $i, $x): holds a validated element/index/value.
return nil
}
// validateClaimsReference requires a node to reference .Claims (or $.Claims),
// never the root map — used for get's map argument, whose key is separately
// enforced against the whitelist at render time.
func (v *templateValidator) validateClaimsReference(node parse.Node, dotIsRoot bool) error {
switch ref := node.(type) {
case *parse.FieldNode:
if dotIsRoot && len(ref.Ident) >= 1 && ref.Ident[0] == "Claims" {
if len(ref.Ident) >= 2 && !v.allowed[ref.Ident[1]] {
return fmt.Errorf("access to Claims.%s is not allowed for security reasons", ref.Ident[1])
}
return nil
}
case *parse.VariableNode:
if len(ref.Ident) >= 2 && ref.Ident[0] == "$" && ref.Ident[1] == "Claims" {
if len(ref.Ident) >= 3 && !v.allowed[ref.Ident[2]] {
return fmt.Errorf("access to Claims.%s is not allowed for security reasons", ref.Ident[2])
}
return nil
}
}
return fmt.Errorf("expected a .Claims reference")
}
// validateClaimSelector validates the target of a range/with: it must iterate a
// specific whitelisted claim field (.Claims.<field> or $.Claims.<field>), never
// the bare Claims map or any non-claims value.
func (v *templateValidator) validateClaimSelector(pipe *parse.PipeNode, dotIsRoot bool, kind string) error {
if pipe == nil || len(pipe.Cmds) == 0 {
return fmt.Errorf("%s requires a .Claims.<field> target", kind)
}
cmd := pipe.Cmds[len(pipe.Cmds)-1]
if len(cmd.Args) == 0 {
return fmt.Errorf("%s requires a .Claims.<field> target", kind)
}
target := cmd.Args[0]
if err := v.validateClaimField(target, dotIsRoot); err != nil {
return fmt.Errorf("%s is only allowed over a whitelisted claims field, e.g. {{range $i, $e := .Claims.groups}} or {{with .Claims.email}}: %w", kind, err)
}
return nil
}
// validateClaimField requires a node to be a specific whitelisted claim field
// (.Claims.<whitelisted> or $.Claims.<whitelisted>) — stricter than
// validateClaimsReference, which also permits the bare .Claims map for get.
func (v *templateValidator) validateClaimField(node parse.Node, dotIsRoot bool) error {
switch field := node.(type) {
case *parse.FieldNode:
if dotIsRoot && len(field.Ident) >= 2 && field.Ident[0] == "Claims" && v.allowed[field.Ident[1]] {
return nil
}
case *parse.VariableNode:
if len(field.Ident) >= 3 && field.Ident[0] == "$" && field.Ident[1] == "Claims" && v.allowed[field.Ident[2]] {
return nil
}
}
return fmt.Errorf("not a whitelisted .Claims.<field>")
}
+98 -83
View File
@@ -91,100 +91,115 @@ type TraefikOidc struct {
// Hot-path readers (middleware.ServeHTTP, token verification) load it
// directly; less-frequent paths still acquire metadataMu.RLock and
// read the individual fields below.
metadataSnapshot atomic.Value
metadataSnapshot atomic.Value
// lastMetadataRetryNano is the UnixNano timestamp of the last metadata
// recovery attempt. Stored atomically so the hot ServeHTTP path can
// throttle retries without acquiring metadataRetryMutex on every request.
lastMetadataRetryNano int64
lastMetadataRetryNano int64
// firstRequestStarted is 0 until the very first non-health request fires
// the background-task bootstrap; then it flips to 1 via CAS. Replaces the
// firstRequestMutex + firstRequestReceived combo which previously took
// a write lock on every non-health request forever.
firstRequestStarted int32
firstRequestStarted int32
// metadataRefreshStartedAtomic is the CAS-only variant of the old
// metadataRefreshStarted bool. Both flags live under the same atomic so
// concurrent first-request goroutines race exactly once.
metadataRefreshStartedAtomic int32
jwkCache JWKCacheInterface
jwtVerifier JWTVerifier
ctx context.Context
tokenVerifier TokenVerifier
next http.Handler
tokenExchanger TokenExchanger
tokenBlacklist CacheInterface
tokenTypeCache CacheInterface
introspectionCache CacheInterface
initComplete chan struct{}
limiter *rate.Limiter
headerTemplates map[string]*template.Template
sessionManager *SessionManager
tokenCleanupStopChan chan struct{}
excludedURLs map[string]struct{}
extractClaimsFunc func(tokenString string) (map[string]any, error)
initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string)
metadataCache *MetadataCache
allowedRolesAndGroups map[string]struct{}
allowedUsers map[string]struct{}
allowedUserDomains map[string]struct{}
tokenCache *TokenCache
httpClient *http.Client
tokenHTTPClient *http.Client
logger *Logger
metadataRefreshStopChan chan struct{}
cancelFunc context.CancelFunc
errorRecoveryManager *ErrorRecoveryManager
tokenResilienceManager *TokenResilienceManager
refreshCoordinator *RefreshCoordinator
goroutineWG *sync.WaitGroup
dcrConfig *DynamicClientRegistrationConfig
dynamicClientRegistrar *DynamicClientRegistrar
scopeFilter *ScopeFilter
securityHeadersApplier func(http.ResponseWriter, *http.Request)
userIdentifierClaim string
revocationURL string
name string
redirURLPath string
logoutURLPath string
tokenURL string
authURL string
endSessionURL string
postLogoutRedirectURI string
jwksURL string
issuerURL string
groupClaimName string
introspectionURL string
providerURL string
roleClaimName string
audience string
clientID string
clientSecret string
clientAuthMethod string
clientAssertion *ClientAssertionSigner
registrationURL string
backchannelLogoutPath string
frontchannelLogoutPath string
scopesSupported []string
scopes []string
extraAuthParams map[string]string
refreshGracePeriod time.Duration
maxRefreshTokenAge time.Duration
metadataMu sync.RWMutex
shutdownOnce sync.Once
sessionInvalidationCache CacheInterface
refreshResultCache CacheInterface
minimalHeaders bool
stripAuthCookies bool
enableBackchannelLogout bool
enableFrontchannelLogout bool
requireTokenIntrospection bool
allowPrivateIPAddresses bool
disableReplayDetection bool
allowOpaqueTokens bool
strictAudienceValidation bool
overrideScopes bool
enablePKCE bool
forceHTTPS bool
suppressDiagnosticLogs bool
jwkCache JWKCacheInterface
jwtVerifier JWTVerifier
ctx context.Context
tokenVerifier TokenVerifier
next http.Handler
tokenExchanger TokenExchanger
tokenBlacklist CacheInterface
tokenTypeCache CacheInterface
introspectionCache CacheInterface
initComplete chan struct{}
limiter *rate.Limiter
headerTemplates map[string]*template.Template
sessionManager *SessionManager
tokenCleanupStopChan chan struct{}
excludedURLs map[string]struct{}
extractClaimsFunc func(tokenString string) (map[string]any, error)
initiateAuthenticationFunc func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string)
metadataCache *MetadataCache
allowedRolesAndGroups map[string]struct{}
allowedUsers map[string]struct{}
allowedUserDomains map[string]struct{}
tokenCache *TokenCache
httpClient *http.Client
tokenHTTPClient *http.Client
logger *Logger
metadataRefreshStopChan chan struct{}
cancelFunc context.CancelFunc
errorRecoveryManager *ErrorRecoveryManager
tokenResilienceManager *TokenResilienceManager
refreshCoordinator *RefreshCoordinator
goroutineWG *sync.WaitGroup
dcrConfig *DynamicClientRegistrationConfig
dynamicClientRegistrar *DynamicClientRegistrar
scopeFilter *ScopeFilter
securityHeadersApplier func(http.ResponseWriter, *http.Request)
userIdentifierClaim string
revocationURL string
name string
redirURLPath string
logoutURLPath string
tokenURL string
authURL string
endSessionURL string
postLogoutRedirectURI string
jwksURL string
issuerURL string
groupClaimName string
introspectionURL string
providerURL string
roleClaimName string
audience string
clientID string
clientSecret string
clientAuthMethod string
clientAssertion *ClientAssertionSigner
registrationURL string
// configRevocationURL, configEndSessionURL, and configIntrospectionURL
// hold operator-configured endpoint overrides (Config.RevocationURL,
// Config.OIDCEndSessionURL, Config.IntrospectionURL). They are set once at
// construction and never mutated afterwards, so they are read lock-free,
// the same contract as providerURL, which updateMetadataEndpoints already
// reads unlocked. A manual override always wins over discovery; an empty
// string means use the discovered value.
configRevocationURL string
configEndSessionURL string
configIntrospectionURL string
backchannelLogoutPath string
frontchannelLogoutPath string
scopesSupported []string
scopes []string
extraAuthParams map[string]string
refreshGracePeriod time.Duration
maxRefreshTokenAge time.Duration
metadataMu sync.RWMutex
shutdownOnce sync.Once
sessionInvalidationCache CacheInterface
refreshResultCache CacheInterface
minimalHeaders bool
stripAuthCookies bool
enableBackchannelLogout bool
enableFrontchannelLogout bool
requireTokenIntrospection bool
allowPrivateIPAddresses bool
// allowLoopbackHosts permits loopback/localhost hosts in outbound URL
// validation (validateHost). Derived at construction time from a loopback
// providerURL (local development), never operator-set directly. Mirrors
// the allowLoopback derivation in updateMetadataEndpoints.
allowLoopbackHosts bool
disableReplayDetection bool
allowOpaqueTokens bool
strictAudienceValidation bool
overrideScopes bool
enablePKCE bool
forceHTTPS bool
suppressDiagnosticLogs bool
// Bearer-auth runtime state (populated only when EnableBearerAuth=true).
bearerIdentifierClaim string
+20 -7
View File
@@ -390,8 +390,15 @@ func (t *TraefikOidc) validateHost(host string) error {
ip := net.ParseIP(hostname)
if ip != nil {
// Always block loopback, link-local, and multicast addresses
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
// Loopback addresses are blocked by default, but allowed when the
// plugin was constructed with a loopback providerURL (local
// development) — see allowLoopbackHosts.
if ip.IsLoopback() && !t.allowLoopbackHosts {
return fmt.Errorf("access to loopback/link-local IP addresses is not allowed: %s", ip.String())
}
// Link-local and multicast addresses are always blocked.
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return fmt.Errorf("access to loopback/link-local IP addresses is not allowed: %s", ip.String())
}
@@ -405,16 +412,22 @@ func (t *TraefikOidc) validateHost(host string) error {
}
}
dangerousHosts := map[string]bool{
"localhost": true,
"127.0.0.1": true,
"::1": true,
// Always blocked regardless of allowLoopbackHosts: these have no IP-branch
// backstop (net.ParseIP returns nil for hostnames), so bypassing them
// would open an SSRF path to the cloud metadata service.
alwaysDangerousHosts := map[string]bool{
"0.0.0.0": true,
"169.254.169.254": true,
"metadata.google.internal": true,
}
if dangerousHosts[strings.ToLower(hostname)] {
if alwaysDangerousHosts[strings.ToLower(hostname)] {
return fmt.Errorf("access to dangerous hostname is not allowed: %s", hostname)
}
// localhost/loopback literals are blocked unless allowLoopbackHosts
// permits them (see allowLoopbackHosts doc comment on TraefikOidc).
if !t.allowLoopbackHosts && isLoopbackHost(hostname) {
return fmt.Errorf("access to dangerous hostname is not allowed: %s", hostname)
}
+1 -1
View File
@@ -14,4 +14,4 @@ const devPluginVersion = "0.0.0-dev"
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph,
// not the interpreted plugin). This build-stamped constant is therefore the
// single source of truth for the version reported by anonymous usage telemetry.
const traefikoidcPluginVersion = "1.0.26"
const traefikoidcPluginVersion = "1.0.32"