diff --git a/.traefik.yml b/.traefik.yml index a05a12f..7910e11 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -72,6 +72,13 @@ testData: value: "{{.Claims.email}}" - name: Authorization value: "Bearer {{.AccessToken}}" + # Object/array claim as one JSON header; the claim must be whitelisted. + - name: X-Tenant + value: '{{get .Claims "tenant" | toJson}}' + + # Extends the built-in claim whitelist for templated headers. + allowedClaims: + - tenant # Security headers (default profile is enabled out of the box) securityHeaders: diff --git a/README.md b/README.md index 44dc271..d565aa0 100644 --- a/README.md +++ b/README.md @@ -441,8 +441,34 @@ headers: 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. +target a specific listed claim (e.g. `{{range .Claims.groups}}`); `get`, `default` +and `toJson` are the only functions allowed. + +### Forwarding an object claim as JSON + +`toJson` serializes a claim (object, array or scalar) to compact JSON, so a +backend can parse the whole claim itself instead of the middleware flattening +it into one header per key: + +```yaml +allowedClaims: + - tenant +headers: + - name: X-Tenant + value: '{{get .Claims "tenant" | toJson}}' # {"id":"t-1","plan":"pro"} + - name: X-Groups + value: "{{toJson .Claims.groups}}" # ["admin","users"] +``` + +- The claim must be on the whitelist (built-in or `allowedClaims`). + `{{toJson .Claims}}`, `{{toJson .}}` and non-listed claims are rejected. +- An absent or empty claim skips the header instead of sending `""`. +- Control characters are JSON-escaped, so a claim cannot inject extra header + lines. +- A rendered value over 8192 bytes, or one with bidi-override characters, is + dropped. The oversize case is logged at error level (header name only, never + the value); keep the claim small, since proxies commonly cap headers at + 8-16 KB. > **File-provider users: escape the braces.** Traefik's file provider runs > every dynamic configuration file through Go templating before the plugin diff --git a/bearer_auth.go b/bearer_auth.go index dfdebbb..a494b74 100644 --- a/bearer_auth.go +++ b/bearer_auth.go @@ -223,6 +223,9 @@ func headerInjectionRuneReason(r rune) string { return "" } +// headerReasonTooLong is the headerValueReason result for an over-long value. +const headerReasonTooLong = "exceeds max length" + // headerValueReason reports why value is unsafe to forward as a free-form HTTP // header value, or "" if acceptable. It rejects values over maxLen (maxLen<=0 // disables the check) and values containing control or bidi-override runes, but @@ -230,7 +233,7 @@ func headerInjectionRuneReason(r rune) string { // never includes the value, so it is safe to log. func headerValueReason(value string, maxLen int) string { if maxLen > 0 && len(value) > maxLen { - return "exceeds max length" + return headerReasonTooLong } for _, r := range value { if reason := headerInjectionRuneReason(r); reason != "" { diff --git a/cmd/yaegicheck/main.go b/cmd/yaegicheck/main.go index ec72252..85e5b52 100644 --- a/cmd/yaegicheck/main.go +++ b/cmd/yaegicheck/main.go @@ -139,6 +139,7 @@ func main() { runCheck("middleware-erraborthandler-aborts-under-yaegi", checkErrAbortHandlerAbortsUnderYaegi) runCheck("fix17-setifabsent-claims-once", checkSetIfAbsentUnderYaegi) runCheck("issue162-websocket-forwards-header-templates", checkIssue162WebSocketHeaderTemplates) + runCheck("tojson-header-template", checkToJsonHeaderTemplate) runCheck("streaming-refresh-revoked-grant-rejected", checkStreamingRefreshRevokedGrant) fmt.Println("OK: all yaegi regression checks passed") } @@ -807,6 +808,99 @@ func checkIssue162WebSocketHeaderTemplates() (string, error) { return "", nil } +// checkToJsonHeaderTemplate pins the toJson template helper under the +// interpreter: its (string, error) closure, json.Marshal of an object claim, +// the nil/absent-claim skip and the validator's rejection of a whole-claims dump. +func checkToJsonHeaderTemplate() (string, error) { + discovery := servers.NewOIDCServer(nil) + defer discovery.Close() + + cfg := oidc.CreateConfig() + cfg.ProviderURL = discovery.URL + cfg.ClientID = "yaegi-check-tojson-client" + cfg.ClientSecret = "yaegi-check-tojson-secret" + cfg.CallbackURL = "/oauth2/callback" + cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef" + cfg.AllowedClaims = []string{"tenant"} + cfg.Headers = []oidc.TemplatedHeader{ + {Name: "X-Tenant", Value: `{{ get .Claims "tenant" | toJson }}`}, + {Name: "X-Groups", Value: "{{ toJson .Claims.groups }}"}, + {Name: "X-Missing", Value: "{{ toJson .Claims.department }}"}, + } + if err := cfg.Validate(); err != nil { + return "", fmt.Errorf("toJson headers must validate: %w", err) + } + + bad := oidc.CreateConfig() + *bad = *cfg + bad.Headers = []oidc.TemplatedHeader{{Name: "X-All", Value: "{{ toJson .Claims }}"}} + if err := bad.Validate(); err == nil { + return "", fmt.Errorf("toJson of the whole claims map must fail validation") + } + + var seen http.Header + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.Header.Clone() + }) + h, err := oidc.New(context.Background(), next, cfg, "yaegi-check-tojson") + if err != nil { + return "", fmt.Errorf("New: %w", err) + } + if closer, ok := h.(interface{ Close() error }); ok { + defer closer.Close() + } + + sm, err := oidc.NewSessionManager(cfg.SessionEncryptionKey, cfg.ForceHTTPS, cfg.CookieDomain, cfg.CookiePrefix, time.Duration(cfg.SessionMaxAge)*time.Second, oidc.NewLogger("error")) + if err != nil { + return "", fmt.Errorf("NewSessionManager: %w", err) + } + defer sm.Shutdown() + + setupReq := httptest.NewRequest("GET", "/ws", nil) + session, err := sm.GetSession(setupReq) + if err != nil { + return "", fmt.Errorf("GetSession: %w", err) + } + if err := session.SetAuthenticated(true); err != nil { + return "", fmt.Errorf("SetAuthenticated: %w", err) + } + session.SetUserIdentifier("alice@example.com") + b64 := base64.RawURLEncoding.EncodeToString + sig := make([]byte, 32) + for i := range sig { + sig[i] = byte(i + 1) + } + session.SetIDToken(b64([]byte(`{"alg":"RS256","typ":"JWT"}`)) + "." + b64([]byte(`{"tenant":{"id":"t-1","plan":"pro"},"groups":["a","b"]}`)) + "." + b64(sig)) + rec := httptest.NewRecorder() + if err := session.Save(setupReq, rec); err != nil { + return "", fmt.Errorf("session.Save: %w", err) + } + session.ReturnToPool() + + req := httptest.NewRequest("GET", "/ws", nil) + for _, c := range rec.Result().Cookies() { + req.AddCookie(c) + } + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + rw := httptest.NewRecorder() + h.ServeHTTP(rw, req) + + if seen == nil { + return "", fmt.Errorf("next was not reached (status %d)", rw.Code) + } + if got, want := seen.Get("X-Tenant"), `{"id":"t-1","plan":"pro"}`; got != want { + return "", fmt.Errorf("X-Tenant = %q, want %q", got, want) + } + if got, want := seen.Get("X-Groups"), `["a","b"]`; got != want { + return "", fmt.Errorf("X-Groups = %q, want %q", got, want) + } + if got := seen.Get("X-Missing"); got != "" { + return "", fmt.Errorf("X-Missing = %q, want header skipped", got) + } + return "", nil +} + // checkStreamingRefreshRevokedGrant drives streamingRefresh under the // interpreter through the real RefreshCoordinator and token exchanger: an // expired session whose refresh the IdP answers with invalid_grant must get diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 25cbe98..3241e15 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -571,7 +571,7 @@ be emitted — the standard OIDC claims plus common provider claims (`email`, `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 +on the list, functions other than `get`/`default`/`toJson`, and `range`/`with` that do not target a specific listed claim (e.g. `{{range .Claims}}` is rejected; `{{range .Claims.groups}}` is allowed). @@ -592,6 +592,21 @@ Listed names apply to both direct access (`{{.Claims.employee_id}}`) and claim (e.g. `realm_access.roles`) are already accessible without listing them individually. +**`toJson`** forwards an object or array claim as one JSON header: + +```yaml +allowedClaims: + - tenant +headers: + - name: "X-Tenant" + value: "{{get .Claims \"tenant\" | toJson}}" +``` + +It only serializes values the template can already reach, so the whitelist +still applies; `{{toJson .Claims}}` is rejected. Control characters are +JSON-escaped, and values over 8192 bytes or with bidi-override characters are +dropped; an oversize value is logged at error level (header name only). + --- ## Security Headers diff --git a/main.go b/main.go index 332326e..6a593ab 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "errors" "fmt" "net/http" @@ -85,8 +86,10 @@ var defaultExcludedURLs = map[string]struct{}{ // 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: +// config.AllowedClaims). It exposes exactly three helpers: // - default: substitute a fallback when a value is nil/empty. +// - toJson: serialize a value (e.g. an object claim) to compact JSON; nil and +// "" yield "" so an absent claim skips the header instead of sending `""`. // - 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 @@ -102,6 +105,16 @@ func headerTemplateFuncMap(allowedClaims map[string]bool) template.FuncMap { } return val }, + "toJson": func(val interface{}) (string, error) { + if val == nil || val == "" { + return "", nil + } + b, err := json.Marshal(val) + if err != nil { + return "", err + } + return string(b), nil + }, "get": func(m interface{}, key string) interface{} { if !allowedClaims[key] { return "" diff --git a/middleware.go b/middleware.go index 346cba5..cd1ee80 100644 --- a/middleware.go +++ b/middleware.go @@ -1371,7 +1371,11 @@ func (t *TraefikOidc) applyHeaderTemplates(req *http.Request, p *principal) { // forwarding a tainted value. Do not log the value (it commonly // carries the access token); log only name + reason. if reason := headerValueReason(headerValue, headerTemplateMaxLen); reason != "" { - t.logger.Debugf("Dropping templated header %s: value failed sanitization (%s)", headerName, reason) + if reason == headerReasonTooLong { + t.logger.Errorf("Dropping templated header %s: rendered value exceeds %d bytes", headerName, headerTemplateMaxLen) + } else { + t.logger.Debugf("Dropping templated header %s: value failed sanitization (%s)", headerName, reason) + } continue } req.Header.Set(headerName, headerValue) diff --git a/template_validation.go b/template_validation.go index 0ae8a78..75831d1 100644 --- a/template_validation.go +++ b/template_validation.go @@ -22,6 +22,7 @@ var allowedRootTokenFields = map[string]bool{ var validateTemplateParseFuncs = map[string]any{ "get": struct{}{}, "default": struct{}{}, + "toJson": struct{}{}, } // claimsWhitelist returns the effective set of claim field names a header @@ -183,12 +184,14 @@ func (v *templateValidator) validateCommand(cmd *parse.CommandNode, dotIsRoot bo return nil } -// validateFunctionCall allows only get/default. get's map argument must be a +// validateFunctionCall allows only get/default/toJson. 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": + case "default", "toJson": + // toJson only serializes what its (validated) arguments or piped input + // already expose; it cannot reach anything a direct reference could not. for _, arg := range args { if err := v.validateValue(arg, dotIsRoot); err != nil { return err diff --git a/tojson_template_test.go b/tojson_template_test.go new file mode 100644 index 0000000..5e6369f --- /dev/null +++ b/tojson_template_test.go @@ -0,0 +1,286 @@ +package traefikoidc + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "text/template" +) + +// renderHeaderTemplate runs tmplStr through the same validator, funcMap and +// applyHeaderTemplates path production uses. validateErr is the static-validation +// error; got is the forwarded header value ("" when absent or dropped). +func renderHeaderTemplate(t *testing.T, tmplStr string, extraClaims []string, claims map[string]interface{}) (got string, validateErr error) { + t.Helper() + allowed := claimsWhitelist(extraClaims) + if err := validateTemplateSecure(tmplStr, allowed); err != nil { + return "", err + } + tmpl, err := template.New("X-Test").Funcs(headerTemplateFuncMap(allowed)).Option("missingkey=zero").Parse(tmplStr) + if err != nil { + t.Fatalf("validated template failed to parse: %v", err) + } + o := &TraefikOidc{logger: NewLogger("error"), headerTemplates: map[string]*template.Template{"X-Test": tmpl}} + req := httptest.NewRequest("GET", "/", nil) + o.applyHeaderTemplates(req, &principal{Claims: claims, AccessToken: "AT-SECRET", IDToken: "IDT-SECRET", RefreshToken: "RT-SECRET"}) + return req.Header.Get("X-Test"), nil +} + +func TestToJson_ObjectClaimRoundTrips(t *testing.T) { + claims := map[string]interface{}{ + "tenant": map[string]interface{}{"id": "t-1", "plan": "pro", "flags": []interface{}{"a", "b"}, "n": 3.0, "ok": true, "nil": nil}, + } + for _, tmpl := range []string{ + `{{ get .Claims "tenant" | toJson }}`, + `{{ toJson (get .Claims "tenant") }}`, + `{{ toJson .Claims.tenant }}`, + `{{ with .Claims.tenant }}{{ toJson . }}{{ end }}`, + `{{ $t := .Claims.tenant }}{{ toJson $t }}`, + `{{ toJson $.Claims.tenant }}`, + } { + got, err := renderHeaderTemplate(t, tmpl, []string{"tenant"}, claims) + if err != nil { + t.Fatalf("%s: unexpected validation error: %v", tmpl, err) + } + var back map[string]interface{} + if err := json.Unmarshal([]byte(got), &back); err != nil { + t.Fatalf("%s: output %q is not valid JSON: %v", tmpl, got, err) + } + if back["id"] != "t-1" || back["plan"] != "pro" || back["n"] != 3.0 || back["ok"] != true { + t.Fatalf("%s: round trip mismatch: %q", tmpl, got) + } + } +} + +func TestToJson_ValueTypes(t *testing.T) { + claims := map[string]interface{}{ + "groups": []interface{}{"admin", "users"}, + "email": "a@b.c", + "exp": 1700000000.0, + "role": "", + } + cases := []struct{ name, tmpl, want string }{ + {"array", `{{ toJson .Claims.groups }}`, `["admin","users"]`}, + {"string", `{{ toJson .Claims.email }}`, `"a@b.c"`}, + {"number", `{{ toJson .Claims.exp }}`, `1700000000`}, + {"missing claim skips header", `{{ toJson .Claims.department }}`, ``}, + {"missing via get skips header", `{{ get .Claims "department" | toJson }}`, ``}, + {"empty string skips header", `{{ toJson .Claims.role }}`, ``}, + {"literal string", `{{ toJson "x" }}`, `"x"`}, + {"literal number", `{{ toJson 5 }}`, `5`}, + {"composes with surrounding text", `v={{ toJson .Claims.groups }};`, `v=["admin","users"];`}, + } + for _, c := range cases { + got, err := renderHeaderTemplate(t, c.tmpl, nil, claims) + if err != nil { + t.Fatalf("%s: unexpected validation error: %v", c.name, err) + } + if got != c.want { + t.Errorf("%s: got %q want %q", c.name, got, c.want) + } + } +} + +func TestToJson_EmptyAndNilContainers(t *testing.T) { + claims := map[string]interface{}{ + "realm_access": map[string]interface{}{}, + "groups": []interface{}{}, + } + if got, _ := renderHeaderTemplate(t, `{{ toJson .Claims.realm_access }}`, nil, claims); got != `{}` { + t.Errorf("empty object: got %q", got) + } + if got, _ := renderHeaderTemplate(t, `{{ toJson .Claims.groups }}`, nil, claims); got != `[]` { + t.Errorf("empty array: got %q", got) + } + if got, _ := renderHeaderTemplate(t, `{{ toJson .Claims.groups }}`, nil, nil); got != `` { + t.Errorf("nil claims: got %q", got) + } +} + +func TestToJson_UnmarshalableValueDropsHeader(t *testing.T) { + fn := headerTemplateFuncMap(nil)["toJson"].(func(interface{}) (string, error)) + if _, err := fn(make(chan int)); err == nil { + t.Fatal("expected error for unmarshalable value") + } + // A non-finite float is the only way a claim map could carry one in. + if _, err := fn(map[string]interface{}{"x": func() {}}); err == nil { + t.Fatal("expected error for func value") + } +} + +// Security: toJson must not widen what a template can reach. +func TestToJson_SecurityBoundary(t *testing.T) { + claims := map[string]interface{}{ + "email": "a@b.c", + "secret": "NOT-WHITELISTED", + "tenant": map[string]interface{}{"id": "t"}, + } + rejected := []string{ + `{{ toJson . }}`, + `{{ toJson $ }}`, + `{{ toJson .Claims }}`, + `{{ toJson $.Claims }}`, + `{{ .Claims | toJson }}`, + `{{ toJson .Claims.secret }}`, + `{{ toJson $.Claims.secret }}`, + `{{ toJson .AccessToken }}{{ toJson .Claims }}`, + `{{ get .Claims "secret" | toJson }}`, + `{{ toJson (get . "AccessToken") }}`, + `{{ get . "Claims" | toJson }}`, + `{{ with .Claims }}{{ toJson . }}{{ end }}`, + `{{ range .Claims }}{{ toJson . }}{{ end }}`, + `{{ toJson (index .Claims "secret") }}`, + `{{ toJson (printf "%v" .Claims) }}`, + `{{ toJson (call .Claims) }}`, + `{{ tojson .Claims.email }}`, + `{{ toJSON .Claims.email }}`, + `{{ toJson | js }}`, + } + for _, tmpl := range rejected { + if got, err := renderHeaderTemplate(t, tmpl, nil, claims); err == nil { + t.Errorf("%s: must be rejected by validation, rendered %q", tmpl, got) + } + } + + // Dynamic key: static validation cannot see it, the runtime whitelist must. + dyn := `{{ get .Claims .Claims.email | toJson }}` + if got, err := renderHeaderTemplate(t, dyn, nil, map[string]interface{}{"email": "secret", "secret": "LEAK"}); err != nil || strings.Contains(got, "LEAK") { + t.Errorf("dynamic key leaked a non-whitelisted claim: err=%v got=%q", err, got) + } +} + +// Security: tokens and the claims map never appear in output, even from a +// validated template that serializes a whitelisted claim. +func TestToJson_NoTokenLeak(t *testing.T) { + claims := map[string]interface{}{"tenant": map[string]interface{}{"id": "t"}} + got, err := renderHeaderTemplate(t, `{{ toJson .Claims.tenant }}`, []string{"tenant"}, claims) + if err != nil { + t.Fatal(err) + } + for _, s := range []string{"AT-SECRET", "IDT-SECRET", "RT-SECRET"} { + if strings.Contains(got, s) { + t.Fatalf("token %s leaked into %q", s, got) + } + } +} + +// Security: a non-whitelisted claim stays unreachable until the operator opts +// in via allowedClaims, with or without toJson. +func TestToJson_RequiresAllowedClaims(t *testing.T) { + claims := map[string]interface{}{"tenant": map[string]interface{}{"id": "t"}} + if _, err := renderHeaderTemplate(t, `{{ toJson .Claims.tenant }}`, nil, claims); err == nil { + t.Error("direct reference to non-whitelisted claim must be rejected") + } + got, err := renderHeaderTemplate(t, `{{ get .Claims "tenant" | toJson }}`, nil, claims) + if err == nil { + t.Errorf("literal get key outside whitelist must be rejected, rendered %q", got) + } +} + +// Security: hostile claim content must not produce header injection. +func TestToJson_HeaderInjectionHardening(t *testing.T) { + cases := []struct { + name string + value interface{} + // forwarded reports whether the header is expected to reach upstream. + forwarded bool + }{ + {"CRLF is escaped to \\r\\n", map[string]interface{}{"x": "a\r\nX-Evil: 1"}, true}, + {"NUL and control chars escaped", map[string]interface{}{"x": "a\x00\x07b"}, true}, + {"LS/PS escaped", map[string]interface{}{"x": "a

b"}, true}, + {"HTML chars escaped", map[string]interface{}{"x": "