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": "