mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-11-02 07:34:05 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
573c6b7496 | ||
|
|
f0af6282dc | ||
|
|
7c5a86f4e1 |
@@ -1,52 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain dependencies for GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
reviewers:
|
||||
- "lukaszraczylo"
|
||||
|
||||
# Maintain Go module dependencies
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "go"
|
||||
reviewers:
|
||||
- "lukaszraczylo"
|
||||
# Group patch updates together
|
||||
groups:
|
||||
patch-updates:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "patch"
|
||||
minor-updates:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
# Ignore certain dependencies if needed
|
||||
ignore:
|
||||
# Example: ignore specific versions
|
||||
# - dependency-name: "github.com/example/package"
|
||||
# versions: ["1.x", "2.x"]
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-1
@@ -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 != "" {
|
||||
|
||||
@@ -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("[email protected]")
|
||||
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
|
||||
|
||||
+16
-1
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "[email protected]",
|
||||
"exp": 1700000000.0,
|
||||
"role": "",
|
||||
}
|
||||
cases := []struct{ name, tmpl, want string }{
|
||||
{"array", `{{ toJson .Claims.groups }}`, `["admin","users"]`},
|
||||
{"string", `{{ toJson .Claims.email }}`, `"[email protected]"`},
|
||||
{"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": "[email protected]",
|
||||
"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": "<script>&"}, true},
|
||||
{"quote and backslash escaped", map[string]interface{}{"x": `"\`}, true},
|
||||
{"bidi override dropped by sanitizer", map[string]interface{}{"x": "a\u202Eb"}, false},
|
||||
{"bidi isolate dropped by sanitizer", map[string]interface{}{"x": "a\u2066b"}, false},
|
||||
{"oversize dropped by sanitizer", map[string]interface{}{"x": strings.Repeat("A", headerTemplateMaxLen)}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := renderHeaderTemplate(t, `{{ toJson .Claims.realm_access }}`, nil, map[string]interface{}{"realm_access": c.value})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
if !c.forwarded {
|
||||
if got != "" {
|
||||
t.Errorf("%s: tainted value must be dropped, got %d bytes", c.name, len(got))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got == "" {
|
||||
t.Errorf("%s: safe escaped JSON must be forwarded", c.name)
|
||||
continue
|
||||
}
|
||||
for _, r := range got {
|
||||
if r < 0x20 || r == 0x7f || r == 0x2028 || r == 0x2029 {
|
||||
t.Errorf("%s: raw control rune %U in %q", c.name, r, got)
|
||||
}
|
||||
}
|
||||
if strings.ContainsAny(got, "<>&") {
|
||||
t.Errorf("%s: unescaped HTML char in %q", c.name, got)
|
||||
}
|
||||
var back map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(got), &back); err != nil {
|
||||
t.Errorf("%s: not valid JSON: %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default-preserving: existing templates are unaffected and toJson is not
|
||||
// available to anything but validated header templates.
|
||||
func TestToJson_ExistingTemplatesUnchanged(t *testing.T) {
|
||||
claims := map[string]interface{}{"email": "[email protected]", "groups": []interface{}{"x", "y"}}
|
||||
got, err := renderHeaderTemplate(t, `{{ .Claims.email }}|{{range $i, $e := .Claims.groups}}{{if $i}},{{end}}{{$e}}{{end}}|{{ default "d" .Claims.role }}`, nil, claims)
|
||||
if err != nil || got != "[email protected]|x,y|d" {
|
||||
t.Fatalf("got %q err=%v", got, err)
|
||||
}
|
||||
if _, ok := headerTemplateFuncMap(nil)["toJson"]; !ok {
|
||||
t.Fatal("toJson missing from func map")
|
||||
}
|
||||
for _, name := range []string{"env", "expandenv", "printf", "call", "index", "js"} {
|
||||
if _, ok := headerTemplateFuncMap(nil)[name]; ok {
|
||||
t.Errorf("%s must not be exposed", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToJson_ConfigValidate(t *testing.T) {
|
||||
cfg := issue149BaseConfig()
|
||||
cfg.AllowedClaims = []string{"tenant"}
|
||||
cfg.Headers = []TemplatedHeader{{Name: "X-Tenant", Value: `{{ get .Claims "tenant" | toJson }}`}}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("toJson header must validate: %v", err)
|
||||
}
|
||||
cfg.Headers = []TemplatedHeader{{Name: "X-All", Value: `{{ toJson .Claims }}`}}
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("toJson of whole Claims must fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
// Oversize output is operator-actionable, so it logs at error level (name and
|
||||
// size limit only, never the value); other sanitizer drops stay at debug.
|
||||
func TestToJson_OversizeLogsError(t *testing.T) {
|
||||
run := func(claim map[string]interface{}) string {
|
||||
var buf strings.Builder
|
||||
logger := NewLogger("debug")
|
||||
logger.logError.SetOutput(&buf)
|
||||
tmpl := template.Must(template.New("X-Test").Funcs(headerTemplateFuncMap(nil)).Parse(`{{ toJson .Claims.realm_access }}`))
|
||||
o := &TraefikOidc{logger: logger, headerTemplates: map[string]*template.Template{"X-Test": tmpl}}
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
o.applyHeaderTemplates(req, &principal{Claims: map[string]interface{}{"realm_access": claim}})
|
||||
if req.Header.Get("X-Test") != "" {
|
||||
t.Fatal("tainted header must be dropped")
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
big := strings.Repeat("S3CRET", headerTemplateMaxLen)
|
||||
out := run(map[string]interface{}{"x": big})
|
||||
if !strings.Contains(out, "X-Test") || !strings.Contains(out, "exceeds") {
|
||||
t.Errorf("oversize must log an error naming the header, got %q", out)
|
||||
}
|
||||
if strings.Contains(out, "S3CRET") {
|
||||
t.Error("error log must not contain the value")
|
||||
}
|
||||
if out := run(map[string]interface{}{"x": "a\u202Eb"}); out != "" {
|
||||
t.Errorf("non-length sanitizer drop must not log at error level, got %q", out)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -14,4 +14,4 @@ const devPluginVersion = "0.0.0-dev"
|
||||
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph,
|
||||
// not the interpreted plugin). This build-stamped constant is therefore the
|
||||
// single source of truth for the version reported by anonymous usage telemetry.
|
||||
const traefikoidcPluginVersion = "0.0.0-dev"
|
||||
const traefikoidcPluginVersion = "1.0.44"
|
||||
|
||||
Reference in New Issue
Block a user