mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-11-03 07:44:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd2226dc47 | ||
|
|
f728f61777 | ||
|
|
78b6f193b3 | ||
|
|
edb7174cc8 | ||
|
|
e51bd9cdbe | ||
|
|
f320d0a316 | ||
|
|
48a19e04f8 |
+10
-5
@@ -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
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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>")
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user