Compare commits

..
1 Commits
Author SHA1 Message Date
github-actions[bot] 8ffd80193a chore(release): v1.0.26 2026-05-30 13:13:08 +00:00
17 changed files with 329 additions and 1544 deletions
+5 -9
View File
@@ -60,18 +60,14 @@ testData:
scopes:
- roles
# 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").
# Templated headers forwarded to backends.
# NOTE: use quadruple braces — the YAML parser collapses {{{{ → {{ so the
# Go template engine receives the correct expression.
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:
+12 -41
View File
@@ -42,7 +42,7 @@ experimental:
plugins:
traefikoidc:
moduleName: github.com/lukaszraczylo/traefikoidc
version: v1.0.29
version: v0.7.10
```
Then attach the middleware in your dynamic configuration (see
@@ -372,50 +372,21 @@ 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`
(or `.IDToken`), `.RefreshToken`. Names are case-sensitive (`.Claims`, not
`.claims`).
Available bindings: `.Claims.<field>`, `.AccessToken`, `.IdToken`,
`.RefreshToken`. Names are case-sensitive (`.Claims`, not `.claims`).
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).
> **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.
## Default downstream headers
@@ -443,7 +414,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` | File provider templated your header value — escape it: ``"{{`{{.AccessToken}}`}}"`` (see "Templated headers"). |
| `can't evaluate field AccessToken in type bool` | Template not escaped — use `{{{{ }}}}`. |
| `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). |
+1 -43
View File
@@ -750,53 +750,11 @@ 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, nil)
err := validateTemplateSecure(tt.template)
if tt.shouldError && err == nil {
t.Errorf("Expected error for template: %s", tt.template)
}
+7 -47
View File
@@ -431,62 +431,22 @@ 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 (only whitelisted fields; see below)
- `{{.Claims.field}}` - ID token claims
- `{{.AccessToken}}` - Raw access token
- `{{.IdToken}}` / `{{.IDToken}}` - Raw ID token (both spellings work)
- `{{.IdToken}}` - Raw ID token
- `{{.RefreshToken}}` - Raw refresh token
**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.
**Important:** Use double curly braces (`{{{{` and `}}}}`) to escape templates in YAML.
---
+2 -2
View File
@@ -51,7 +51,7 @@ experimental:
plugins:
traefikoidc:
moduleName: github.com/lukaszraczylo/traefikoidc
version: v1.0.29
version: v0.8.0
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=v1.0.29"
- "--experimental.plugins.traefikoidc.version=v0.8.0"
ports:
- "80:80"
- "443:443"
+1 -1
View File
@@ -6,7 +6,7 @@ experimental:
plugins:
traefikoidc:
moduleName: github.com/lukaszraczylo/traefikoidc
version: v1.0.29
version: v0.8.0
# Dynamic configuration (dynamic.yml or labels)
http:
-303
View File
@@ -1,303 +0,0 @@
package traefikoidc
import (
"bytes"
"strings"
"testing"
"text/template"
)
// Issue #149: after NewWithContext started calling config.Validate() (commit
// 546ceb9), `headers` configs that had worked in 0.8.25 began failing, so New()
// returned a nil handler and Traefik logged the opaque router error
// "invalid handler type: <nil>". Two independent header-validation checks were
// too strict:
//
// 1. validateTemplateSecure rejected the documented multi-valued-claim join
// pattern "{{range .. := .Claims.groups}}..." as a dangerous {{range}}.
// 2. Config.Validate required every header value to contain {{ }}, rejecting
// static/literal values (e.g. a shared proxy secret).
//
// These tests pin the fix while proving the security boundary still holds.
// issue149BaseConfig returns a complete, valid config with no headers. Header
// cases are layered on per-test. No network is performed by Config.Validate.
func issue149BaseConfig() *Config {
return &Config{
ProviderURL: "https://provider.example.com",
ClientID: "frigate-client",
ClientSecret: "frigate-client-secret",
CallbackURL: "/oauth2/callback",
SessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789",
RateLimit: 100,
Scopes: []string{"openid", "profile", "email", "groups"},
}
}
// TestIssue149_HeadersConfigValidates is the exact reproduction: the issue's
// three headers (after Traefik file-provider un-escaping) must now validate.
func TestIssue149_HeadersConfigValidates(t *testing.T) {
cfg := issue149BaseConfig()
cfg.Headers = []TemplatedHeader{
{Name: "X-Forwarded-Preferred-Username", Value: `{{.Claims.preferred_username}}`},
{Name: "X-Forwarded-Groups", Value: `{{range $i, $e := .Claims.groups}}{{if $i}},{{end}}{{$e}}{{end}}`},
{Name: "X-Proxy-Secret", Value: `super-secret-shared-value`},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("issue #149 headers config must validate after the fix, got: %v", err)
}
}
// TestIssue149_TemplateSecurityBoundary asserts the relaxed validator accepts
// the documented range/if join over claims while still blocking genuinely
// dangerous templates and access to non-whitelisted claims.
func TestIssue149_TemplateSecurityBoundary(t *testing.T) {
cases := []struct {
name string
template string
shouldFail bool
}{
// --- allowed: documented, safe patterns ---
{"direct claim", `{{.Claims.preferred_username}}`, false},
{"documented groups join", `{{range $i, $e := .Claims.groups}}{{if $i}},{{end}}{{$e}}{{end}}`, false},
{"documented roles join", `{{range $i, $e := .Claims.roles}}{{if $i}},{{end}}{{$e}}{{end}}`, false},
{"simple range over claims", `{{range .Claims.groups}}{{.}} {{end}}`, false},
{"default over claim", `{{default "none" .Claims.email}}`, false},
{"access token", `{{.AccessToken}}`, false},
{"with over claim field", `{{with .Claims.email}}{{.}}{{end}}`, false},
{"id token IdToken spelling", `{{.IdToken}}`, false}, // documented spelling (C6)
{"id token IDToken spelling", `{{.IDToken}}`, false}, // actual data key (C6)
// --- still blocked: with over the bare Claims map leaks arbitrary fields ---
{"with over bare claims map", `{{with .Claims}}{{.password}}{{end}}`, true},
// --- still blocked: dangerous control/functions ---
{"range over non-claims data", `{{range .Items}}{{.}}{{end}}`, true},
{"call function", `{{call .Func}}`, true},
{"define template", `{{define "x"}}{{.}}{{end}}`, true},
{"template inclusion", `{{template "other"}}`, true},
{"printf", `{{printf "%s" .}}`, true},
// --- still blocked: non-whitelisted claim access, incl. via range ---
{"non-whitelisted claim direct", `{{.Claims.password}}`, true},
{"non-whitelisted claim via range", `{{range $i, $e := .Claims.ssn}}{{$e}}{{end}}`, true},
{"no recognized variable", `{{.Unknown}}`, true},
// --- still blocked: whole-Claims-map dump via range (issue #149 review, C1).
// A decoy {{.Claims.sub}} must NOT launder a bare-map range past the gate.
{"whole claims map dump via range", `{{range .Claims}}{{.}} {{end}}{{.Claims.sub}}`, true},
{"claims keys dump via range assign", `{{range $k, $v := .Claims}}{{$k}} {{end}}{{.Claims.sub}}`, true},
// --- still blocked: newline-obfuscated actions — the AST parser resolves
// whitespace, so these are just {{range}}/{{index}} and are rejected.
{"newline-obfuscated range over map", "{{\nrange .Claims}}{{.}}{{end}}{{.Claims.sub}}", true},
{"newline-obfuscated index exfil", "{{\nindex .Claims \"password\"}}{{.Claims.sub}}", true},
// --- still blocked: whole-root / whole-Claims-map render (AST review). A
// whitelisted decoy {{.Claims.email}} must NOT launder a bare-map/root dump.
{"whole claims map render", `{{.Claims}}`, true},
{"whole root render dot", `{{.}}`, true},
{"whole root render dollar", `{{$}}`, true},
{"root claims via dollar", `{{$.Claims}}`, true},
{"decoy plus root dump", `{{.Claims.email}}{{.}}`, true},
{"decoy plus map dump", `{{.Claims.email}}{{.Claims}}`, true},
{"var-laundered map dump", `{{$x := .Claims}}{{$x}}{{.Claims.sub}}`, true},
{"index builtin blocked", `{{index .Claims "password"}}`, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateTemplateSecure(tc.template, nil)
if tc.shouldFail && err == nil {
t.Fatalf("expected %q to be rejected, but it passed", tc.template)
}
if !tc.shouldFail && err != nil {
t.Fatalf("expected %q to be allowed, but it was rejected: %v", tc.template, err)
}
})
}
}
// TestIssue149_GroupsTemplateRendersForwardableValue proves the end goal: the
// documented groups join template parses, executes to a comma-separated string,
// and passes the runtime header sanitizer — so the header is actually emitted,
// not merely accepted by validation. Parse options mirror main.go:414.
func TestIssue149_GroupsTemplateRendersForwardableValue(t *testing.T) {
const tmplStr = `{{range $i, $e := .Claims.groups}}{{if $i}},{{end}}{{$e}}{{end}}`
tmpl, err := template.New("X-Forwarded-Groups").Option("missingkey=zero").Parse(tmplStr)
if err != nil {
t.Fatalf("documented groups template must parse: %v", err)
}
data := map[string]any{
"Claims": map[string]any{
"groups": []any{"admin", "users", "viewers"},
},
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
t.Fatalf("groups template must execute: %v", err)
}
if got := buf.String(); got != "admin,users,viewers" {
t.Fatalf("groups render mismatch: got %q want %q", got, "admin,users,viewers")
}
// The comma-joined value must survive the runtime injection guard; commas
// are not control/bidi runes, so it forwards intact.
if reason := headerValueReason(buf.String(), headerTemplateMaxLen); reason != "" {
t.Fatalf("rendered groups header was dropped by sanitizer: %s", reason)
}
}
// TestIssue149_GetEnforcesClaimsWhitelist proves the runtime `get` helper cannot
// exfiltrate a non-whitelisted claim, a raw token, or the whole data map — even
// though validateTemplateSecure accepts {{get ...}} syntactically. This is the
// defense-in-depth layer for C2/C3/C4 from the issue #149 security review.
func TestIssue149_GetEnforcesClaimsWhitelist(t *testing.T) {
data := map[string]any{
"AccessToken": "AT-SECRET",
"IDToken": "IDT",
"IdToken": "IDT",
"RefreshToken": "RT-SECRET",
"Claims": map[string]any{
"email": "[email protected]",
"password": "PW-SECRET",
"ssn": "123-45-6789",
},
}
fns := headerTemplateFuncMap(nil)
render := func(tmplStr string) string {
tmpl := template.Must(template.New("h").Funcs(fns).Option("missingkey=zero").Parse(tmplStr))
var b bytes.Buffer
if err := tmpl.Execute(&b, data); err != nil {
t.Fatalf("execute %q: %v", tmplStr, err)
}
return b.String()
}
// Every exfil attempt must render empty (or the default fallback), never a secret.
leaky := map[string]string{
"get non-whitelisted claim": `{{get .Claims "password"}}`,
"get ssn": `{{get .Claims "ssn"}}`,
"get raw token off root": `{{get . "AccessToken"}}`,
"get whole claims map": `{{get . "Claims"}}`,
"default wrapping get(ssn)": `{{default "none" (get .Claims "ssn")}}`,
}
for name, tmplStr := range leaky {
t.Run(name, func(t *testing.T) {
got := render(tmplStr)
for _, secret := range []string{"PW-SECRET", "AT-SECRET", "RT-SECRET", "123-45-6789"} {
if strings.Contains(got, secret) {
t.Fatalf("%q leaked %q (rendered %q)", tmplStr, secret, got)
}
}
})
}
// A whitelisted claim via get must still resolve.
if got := render(`{{get .Claims "email"}}`); got != "[email protected]" {
t.Fatalf(`get of whitelisted "email" must work, got %q`, got)
}
}
// TestIssue149_IdTokenBothSpellingsRender pins C6: both {{.IdToken}} (documented)
// and {{.IDToken}} (runtime data key) resolve to the ID token, and both validate.
func TestIssue149_IdTokenBothSpellingsRender(t *testing.T) {
data := map[string]any{"IDToken": "IDT-REAL", "IdToken": "IDT-REAL"}
for _, tmplStr := range []string{`{{.IdToken}}`, `{{.IDToken}}`} {
if err := validateTemplateSecure(tmplStr, nil); err != nil {
t.Fatalf("%q must validate: %v", tmplStr, err)
}
tmpl := template.Must(template.New("h").Option("missingkey=zero").Parse(tmplStr))
var b bytes.Buffer
if err := tmpl.Execute(&b, data); err != nil {
t.Fatalf("execute %q: %v", tmplStr, err)
}
if b.String() != "IDT-REAL" {
t.Fatalf("%q rendered %q, want IDT-REAL", tmplStr, b.String())
}
}
}
// TestIssue149_AllowedClaimsExtendsWhitelist pins the allowedClaims config
// option: a custom claim is rejected by default but validates and renders once
// listed, via both direct access and get.
func TestIssue149_AllowedClaimsExtendsWhitelist(t *testing.T) {
const custom = "employee_id"
// Default (no allowedClaims): the custom claim is not forwardable.
base := issue149BaseConfig()
base.Headers = []TemplatedHeader{{Name: "X-Emp", Value: `{{.Claims.employee_id}}`}}
if err := base.Validate(); err == nil {
t.Fatalf("custom claim %q must be rejected without allowedClaims", custom)
}
// With allowedClaims: direct access and get both validate.
cfg := issue149BaseConfig()
cfg.AllowedClaims = []string{custom}
cfg.Headers = []TemplatedHeader{
{Name: "X-Emp", Value: `{{.Claims.employee_id}}`},
{Name: "X-Emp-Get", Value: `{{get .Claims "employee_id"}}`},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("allowedClaims must permit %q, got: %v", custom, err)
}
// The whitelist (validation) and the runtime get share the same effective set.
whitelist := claimsWhitelist(cfg.AllowedClaims)
if err := validateTemplateSecure(`{{get .Claims "employee_id"}}`, whitelist); err != nil {
t.Fatalf("get on allowed custom claim must validate: %v", err)
}
fns := headerTemplateFuncMap(whitelist)
tmpl := template.Must(template.New("h").Funcs(fns).Option("missingkey=zero").Parse(`{{get .Claims "employee_id"}}`))
var b bytes.Buffer
if err := tmpl.Execute(&b, map[string]any{"Claims": map[string]any{"employee_id": "E-42"}}); err != nil {
t.Fatalf("render: %v", err)
}
if b.String() != "E-42" {
t.Fatalf("runtime get of allowed claim rendered %q, want E-42", b.String())
}
// A still-unlisted claim stays rejected even when another is allowed.
if err := validateTemplateSecure(`{{.Claims.ssn}}`, whitelist); err == nil {
t.Fatal("non-allowed claim ssn must still be rejected")
}
}
// TestIssue149_StaticAndDelimiterHandling covers the Config.Validate header loop
// for literal values and malformed delimiters (validateTemplateSecure is not
// invoked for pure literals).
func TestIssue149_StaticAndDelimiterHandling(t *testing.T) {
cases := []struct {
name string
value string
shouldFail bool
errContains string
}{
{"static literal secret", `super-secret-shared-value`, false, ""},
{"static with equals/base64", `AAAA1234==`, false, ""},
{"static with CRLF is rejected", "line1\r\nline2", true, "invalid characters"},
{"unbalanced open only", `{{.Claims.email`, true, "unbalanced template delimiters"},
{"unbalanced close only", `.Claims.email}}`, true, "unbalanced template delimiters"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := issue149BaseConfig()
cfg.Headers = []TemplatedHeader{{Name: "X-Test", Value: tc.value}}
err := cfg.Validate()
if tc.shouldFail {
if err == nil {
t.Fatalf("expected value %q to be rejected, but it passed", tc.value)
}
if tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) {
t.Fatalf("expected error to contain %q, got: %v", tc.errContains, err)
}
return
}
if err != nil {
t.Fatalf("expected value %q to be allowed, but it was rejected: %v", tc.value, err)
}
})
}
}
-114
View File
@@ -1,114 +0,0 @@
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)
}
}
-326
View File
@@ -1,326 +0,0 @@
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)")
}
}
+22 -59
View File
@@ -9,6 +9,7 @@ import (
"encoding/hex"
"fmt"
"net/http"
"net/url"
"os"
"runtime"
"strings"
@@ -82,39 +83,6 @@ 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.
@@ -135,20 +103,7 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
telemetry.Send("traefikoidc", traefikoidcPluginVersion)
}
})
// 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
return NewWithContext(ctx, config, next, name)
}
// NewWithContext creates a new TraefikOidc middleware instance with proper context handling.
@@ -169,11 +124,6 @@ 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
@@ -313,7 +263,6 @@ 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,
@@ -439,7 +388,22 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
t.headerTemplates = make(map[string]*template.Template)
funcMap := headerTemplateFuncMap(claimsWhitelist(config.AllowedClaims))
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 ""
},
}
for _, header := range config.Headers {
tmpl := template.New(header.Name).Funcs(funcMap).Option("missingkey=zero")
@@ -562,11 +526,10 @@ 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.
// 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)
allowLoopback := false
if pu, err := url.Parse(t.providerURL); err == nil {
allowLoopback = isLoopbackHost(pu.Hostname())
}
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)
-1
View File
@@ -796,7 +796,6 @@ 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,
}
+1 -48
View File
@@ -18,50 +18,6 @@ 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
@@ -369,10 +325,7 @@ 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)
// 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")
assert.Contains(t, err.Error(), "dangerous", "Error should mention dangerous pattern")
}
// Test all safe patterns from the documentation
+187 -79
View File
@@ -55,14 +55,8 @@ type Config struct {
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"`
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
@@ -536,9 +530,7 @@ func (c *Config) Validate() error {
}
}
// Validate headers configuration for template security. Header templates may
// emit the built-in claims plus any operator-configured AllowedClaims.
allowedClaims := claimsWhitelist(c.AllowedClaims)
// Validate headers configuration for template security
for _, header := range c.Headers {
if header.Name == "" {
return fmt.Errorf("header name cannot be empty")
@@ -546,20 +538,8 @@ func (c *Config) Validate() error {
if header.Value == "" {
return fmt.Errorf("header value template cannot be empty")
}
hasOpen := strings.Contains(header.Value, "{{")
hasClose := strings.Contains(header.Value, "}}")
if hasOpen != hasClose {
return fmt.Errorf("header value '%s' has unbalanced template delimiters (need both {{ and }})", header.Value)
}
if !hasOpen {
// Static/literal header value with no template actions (e.g. a shared
// proxy secret or a constant marker). Allowed as-is — it worked before
// the constructor started calling Validate() (issue #149). Reject only
// header-injection characters; the rendered value is emitted verbatim.
if strings.ContainsAny(header.Value, "\r\n\x00") {
return fmt.Errorf("header value for '%s' contains invalid characters (CR/LF/NUL)", header.Name)
}
continue
if !strings.Contains(header.Value, "{{") || !strings.Contains(header.Value, "}}") {
return fmt.Errorf("header value '%s' does not appear to be a valid template (missing {{ }})", header.Value)
}
// Provide more helpful guidance for common template errors BEFORE security validation
@@ -577,7 +557,7 @@ func (c *Config) Validate() error {
}
// Validate template syntax and security
if err := validateTemplateSecure(header.Value, allowedClaims); err != nil {
if err := validateTemplateSecure(header.Value); err != nil {
return fmt.Errorf("header template '%s' failed security validation: %w", header.Value, err)
}
}
@@ -585,50 +565,190 @@ 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.
// validateTemplateSecure lives in template_validation.go (AST-based validation).
func validateTemplateSecure(templateStr string) error {
// Allow our specific safe custom functions
// These are added specifically to handle missing fields safely (issue #60)
safeCustomFunctions := []string{
"{{get ", // Safe map access function
"{{default ", // Safe default value function
}
// Check if template uses safe custom functions
usesSafeFunctions := false
for _, safeFn := range safeCustomFunctions {
if strings.Contains(templateStr, safeFn) {
usesSafeFunctions = true
// These functions are explicitly allowed for safe field access
}
}
// Check for dangerous template functions and patterns
// Skip certain checks if using our safe functions
dangerousPatterns := []string{
"{{call", // Function calls (except our safe ones)
"{{range", // Range over arbitrary data
"{{define", // Template definitions
"{{template", // Template inclusions
"{{block", // Block definitions
"{{/*", // Comments that could hide malicious code
"{{-", // Trim whitespace (could be used to obfuscate)
"-}}", // Trim whitespace (could be used to obfuscate)
"{{printf", // Printf functions
"{{print", // Print functions (but not our safe ones)
"{{println", // Println functions
"{{html", // HTML functions
"{{js", // JavaScript functions
"{{urlquery", // URL query functions
"{{index", // Index access to arbitrary data
"{{slice", // Slice operations
"{{len", // Length operations on arbitrary data
"{{eq", // Comparison operations
"{{ne", // Comparison operations
"{{lt", // Comparison operations
"{{le", // Comparison operations
"{{gt", // Comparison operations
"{{ge", // Comparison operations
"{{and", // Logical operations
"{{or", // Logical operations
"{{not", // Logical operations
}
// Allow 'with' for safe conditional access
if !strings.Contains(templateStr, "{{with .Claims") {
dangerousPatterns = append(dangerousPatterns, "{{with")
}
templateLower := strings.ToLower(templateStr)
for _, pattern := range dangerousPatterns {
// Skip check if it's one of our safe functions
if usesSafeFunctions && (pattern == "{{call" || pattern == "{{print") {
// Allow these if we're using safe functions
continue
}
// Special handling for comparison operators to avoid false positives with "get" and "default"
if pattern == "{{ge" && (strings.Contains(templateStr, "{{get ") || strings.Contains(templateStr, "{{default ")) {
// Skip {{ge check if we're using the safe {{get or {{default functions
continue
}
// Skip {{de checks if using {{default
if pattern == "{{define" && strings.Contains(templateStr, "{{default ") {
continue
}
if strings.Contains(templateLower, strings.ToLower(pattern)) {
return fmt.Errorf("dangerous template pattern detected: %s", pattern)
}
}
// Validate template variables against whitelist
allowedPatterns := []string{
"{{.AccessToken}}",
"{{.IdToken}}",
"{{.RefreshToken}}",
"{{.Claims.",
"{{get ", // Safe custom function
"{{default ", // Safe custom function
"{{with ", // Safe conditional (when used with Claims)
}
// Check if template contains only allowed patterns
hasAllowedPattern := false
for _, pattern := range allowedPatterns {
if strings.Contains(templateStr, pattern) {
hasAllowedPattern = true
break
}
}
if !hasAllowedPattern {
return fmt.Errorf("template must use only allowed variables: AccessToken, IdToken, RefreshToken, Claims.*, or safe functions (get, default, with)")
}
// Validate claims access patterns
if strings.Contains(templateStr, "{{.Claims.") {
// Simple validation - ensure claims access is to known safe fields
// This list includes standard OIDC claims and common provider-specific claims
safeClaimsFields := map[string]bool{
// Standard OIDC claims
"email": true,
"name": true,
"given_name": true,
"family_name": true,
"preferred_username": true,
"sub": true,
"iss": true,
"aud": true,
"exp": true,
"iat": true,
"groups": true,
"roles": true,
// Common custom claims
"internal_role": true, // Custom roles field (issue #60)
"role": true, // Alternative role field
"department": true, // Organization info
"organization": true, // Organization info
// Provider-specific claims
"realm_access": true, // Keycloak specific
"resource_access": true, // Keycloak specific
"oid": true, // Azure AD object ID
"tid": true, // Azure AD tenant ID
"upn": true, // Azure AD User Principal Name
"hd": true, // Google hosted domain
"picture": true, // Profile picture
// Additional standard claims
"locale": true, // User locale
"zoneinfo": true, // Timezone
"phone_number": true, // Contact info
"email_verified": true, // Email verification status
"updated_at": true, // Last update time
}
// Extract field names from Claims access
start := strings.Index(templateStr, "{{.Claims.")
for start != -1 {
end := strings.Index(templateStr[start:], "}}")
if end == -1 {
return fmt.Errorf("malformed Claims template syntax")
}
// Extract the content between "{{.Claims." and "}}"
// start+10 skips "{{.Claims." and start+end is the position of "}}"
claimsContent := templateStr[start+10 : start+end]
// Get the field name (first part before any dots)
fieldName := strings.Split(claimsContent, ".")[0]
if !safeClaimsFields[fieldName] {
return fmt.Errorf("access to Claims.%s is not allowed for security reasons", fieldName)
}
// Search for next occurrence
nextStart := strings.Index(templateStr[start+end+2:], "{{.Claims.")
if nextStart != -1 {
start = start + end + 2 + nextStart
} else {
start = -1
}
}
}
// Prevent code injection through template syntax
if strings.Contains(templateStr, "{{") && strings.Contains(templateStr, "}}") {
// Count opening and closing braces
openCount := strings.Count(templateStr, "{{")
closeCount := strings.Count(templateStr, "}}")
if openCount != closeCount {
return fmt.Errorf("unbalanced template braces")
}
}
return nil
}
// isValidSecureURL checks if a given string represents a valid, absolute HTTPS URL.
// It uses url.Parse and checks for a nil error, an "https" scheme, and a non-empty host.
@@ -671,18 +791,6 @@ func isLoopbackHost(host string) bool {
return false
}
// isLoopbackProviderURL reports whether providerURL's host is loopback
// (localhost or a loopback IP literal). Used at construction time to derive
// allowLoopbackHosts, so local-development providers are not blocked by
// validateHost's outbound URL checks.
func isLoopbackProviderURL(providerURL string) bool {
pu, err := url.Parse(providerURL)
if err != nil {
return false
}
return isLoopbackHost(pu.Hostname())
}
// isValidLogLevel checks if the provided log level string is one of the supported values ("debug", "info", "error").
//
// Parameters:
-362
View File
@@ -1,362 +0,0 @@
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>")
}
+83 -88
View File
@@ -91,105 +91,100 @@ 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
// 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
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
// Bearer-auth runtime state (populated only when EnableBearerAuth=true).
bearerIdentifierClaim string
+7 -20
View File
@@ -390,15 +390,8 @@ func (t *TraefikOidc) validateHost(host string) error {
ip := net.ParseIP(hostname)
if ip != nil {
// 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() {
// Always block loopback, link-local, and multicast addresses
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return fmt.Errorf("access to loopback/link-local IP addresses is not allowed: %s", ip.String())
}
@@ -412,22 +405,16 @@ func (t *TraefikOidc) validateHost(host string) error {
}
}
// 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{
dangerousHosts := map[string]bool{
"localhost": true,
"127.0.0.1": true,
"::1": true,
"0.0.0.0": true,
"169.254.169.254": true,
"metadata.google.internal": true,
}
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) {
if dangerousHosts[strings.ToLower(hostname)] {
return fmt.Errorf("access to dangerous hostname is not allowed: %s", hostname)
}
+1 -1
View File
@@ -14,4 +14,4 @@ const devPluginVersion = "0.0.0-dev"
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph,
// not the interpreted plugin). This build-stamped constant is therefore the
// single source of truth for the version reported by anonymous usage telemetry.
const traefikoidcPluginVersion = "1.0.31"
const traefikoidcPluginVersion = "1.0.26"