From f0af6282dc126b8c8a1aad7007679f31153c6e60 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Mon, 5 Oct 2026 00:20:01 +0100 Subject: [PATCH] feat(templates): add toJson helper for object claims (#165) Header templates can now emit an object or array claim as one JSON header. Example: {{get .Claims "tenant" | toJson}}. The backend parses the claim, so the middleware does not need to know its schema. The helper adds no new access path. The validator treats toJson like default and checks its arguments. A claim must be on the whitelist, and a dump of the whole claims map is rejected. An absent or empty claim skips the header. The JSON encoder escapes control characters. A rendered header value over 8192 bytes is still dropped. The drop is now logged at error level, with the header name and without the value. Other sanitizer drops stay at debug level. Tests cover value types, edge cases, whitelist bypass attempts, token leaks and header injection. A new yaegi check runs the helper under the interpreter. README, configuration docs and the plugin manifest show the new helper. --- .traefik.yml | 7 + README.md | 30 ++++- bearer_auth.go | 5 +- cmd/yaegicheck/main.go | 94 +++++++++++++ docs/CONFIGURATION.md | 17 ++- main.go | 15 ++- middleware.go | 6 +- template_validation.go | 7 +- tojson_template_test.go | 286 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 459 insertions(+), 8 deletions(-) create mode 100644 tojson_template_test.go 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": "