Compare commits

..
4 Commits
Author SHA1 Message Date
github-actions[bot] 573c6b7496 chore(release): v1.0.44 2026-10-04 23:22:51 +00:00
lukaszraczyloandGitHub f0af6282dc 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.
2026-10-05 00:20:01 +01:00
lukaszraczylo 7c5a86f4e1 chore: remove dependabot config 2026-10-04 01:04:57 +01:00
lukaszraczyloandGitHub 94a8664403 perf(session): cut yaegi allocations on authenticated requests (#164)
Under yaegi, the authenticated cookie path allocated about 3.8 MB per
request. A yaegi harness measures bytes per request with a browser-like
cookie jar. With it, these changes cut bytes by 83%, allocations by 69%
and time by about 93%.

- Replace range loops over tokens with index loops. Under yaegi, ranging
  over a string allocates O(n^2) bytes.
- Pool gzip readers for combined session cookies.
- Memoize the validated ID token within one request.
- Cache content-only token validation across requests. The key is the
  token type and SHA-256, and the cache holds at most 4096 entries. The
  entry keeps the parsed exp and iat claims. The expiry and freshness
  checks still run on every request.

New tests pin the validator results, the memo and the cache behavior.
2026-09-29 11:59:31 +01:00
17 changed files with 869 additions and 107 deletions
-52
View File
@@ -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"]
+7
View File
@@ -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:
+28 -2
View File
@@ -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
View File
@@ -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 != "" {
+94
View File
@@ -0,0 +1,94 @@
package traefikoidc
import (
"strings"
"testing"
)
// These tests pin the token-content validators' results, including for
// multi-byte UTF-8 input, so their loops can be rewritten for yaegi (where
// ranging over a string allocates O(n²)) without changing behavior.
func TestDetectRepeatedCharactersPinned(t *testing.T) {
cm := NewChunkManager(NewLogger(""))
cases := []struct {
name string
token string
wantErr string
}{
{"short token skipped", "aaaaaaaaa", ""},
{"varied ascii", "abcdefghijklmnopqrstuvwxyz0123456789", ""},
{"20 repeats allowed", strings.Repeat("a", 20) + strings.Repeat("bcdefghij", 4), ""},
{"21 repeats rejected", strings.Repeat("a", 21) + strings.Repeat("bcdefghij", 4), "excessive repeated characters (21 consecutive)"},
{"multibyte run counted per rune", strings.Repeat("é", 21) + strings.Repeat("bcdefghij", 4), "excessive repeated characters (21 consecutive)"},
// Rune counts are divided by the byte length, so a multi-byte rune
// cannot reach the 70% threshold. Pinned as-is.
{"multibyte frequency divides by byte length", strings.Repeat("éééx", 8), ""},
{"ascii frequency over 70%", strings.Repeat("aaaab", 8), "suspicious character frequency (char 'a'"},
{"multibyte mixed passes", "日本語テキストabcdefghijklmnopqrstuvwxyz", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := cm.detectRepeatedCharacters(tc.token, AccessTokenConfig)
assertPinnedErr(t, err, tc.wantErr)
})
}
}
func TestValidateJWTFormatPinned(t *testing.T) {
cm := NewChunkManager(NewLogger(""))
cases := []struct {
name string
token string
wantErr string
}{
{"valid base64url parts", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2lnbmF0dXJlLXZhbHVl"), ""},
{"padding allowed", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0=", "c2lnbmF0dXJl"), ""},
{"plus rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzd+IiOiJ4In0", "c2lnbmF0dXJl"), "invalid base64url character in part 1"},
{"multibyte rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdé", "c2lnbmF0dXJl"), "invalid base64url character in part 1"},
{"control char rejected", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2ln\x01bmF0"), "invalid base64url character in part 2"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := cm.validateJWTFormat(tc.token, "access")
assertPinnedErr(t, err, tc.wantErr)
})
}
}
func TestValidateTokenSanitizationPinned(t *testing.T) {
cm := NewChunkManager(NewLogger(""))
cases := []struct {
name string
token string
wantErr string
}{
{"clean jwt", jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2lnbmF0dXJl"), ""},
{"multibyte allowed", jwtLike("eyJhbGciOiJSUzI1NiJ9", "日本語", "c2lnbmF0dXJl"), ""},
{"control char byte offset", "abcé\x01def", "control character at position 5"},
{"delete char", "abcdef\x7fgh", "control character at position 6"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := cm.validateTokenSanitization(tc.token, AccessTokenConfig)
assertPinnedErr(t, err, tc.wantErr)
})
}
}
// jwtLike joins parts with dots at runtime; JWT-shaped literals trip the
// pre-commit secret scanner.
func jwtLike(parts ...string) string { return strings.Join(parts, ".") }
func assertPinnedErr(t *testing.T, err error, want string) {
t.Helper()
if want == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want containing %q", err, want)
}
}
+94
View File
@@ -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
+45
View File
@@ -0,0 +1,45 @@
package traefikoidc
import (
"sync"
"testing"
)
func TestDecompressCombinedPayloadReusesReaders(t *testing.T) {
var readers sync.Pool
first, err := compressCombinedPayload(&combinedSessionPayload{Ui: "[email protected]"})
if err != nil {
t.Fatalf("compress first: %v", err)
}
second, err := compressCombinedPayload(&combinedSessionPayload{Ui: "[email protected]"})
if err != nil {
t.Fatalf("compress second: %v", err)
}
cases := []struct {
name string
input string
wantUser string
wantErr bool
}{
{"first payload", first, "[email protected]", false},
{"corrupt payload rejected", "bm90LWd6aXA=", "", true},
{"second payload after corrupt one", second, "[email protected]", false},
{"first payload again", first, "[email protected]", false},
}
for _, tc := range cases {
got, err := decompressCombinedPayload(tc.input, &readers)
if tc.wantErr {
if err == nil {
t.Fatalf("%s: expected error", tc.name)
}
continue
}
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if got.Ui != tc.wantUser {
t.Fatalf("%s: user = %q, want %q", tc.name, got.Ui, tc.wantUser)
}
}
}
+67
View File
@@ -0,0 +1,67 @@
package traefikoidc
import (
"strings"
"testing"
"time"
)
func TestContentValidCache(t *testing.T) {
cm := NewChunkManager(NewLogger(""))
t.Cleanup(cm.Shutdown)
now := time.Now()
valid := tokenWithGroups(t, map[string]interface{}{"sub": "a", "exp": float64(now.Add(time.Hour).Unix()), "iat": float64(now.Unix())})
futureIat := tokenWithGroups(t, map[string]interface{}{"sub": "b", "exp": float64(now.Add(2 * time.Hour).Unix()), "iat": float64(now.Add(time.Hour).Unix())})
badContent := jwtLike("eyJhbGciOiJSUzI1NiJ9", "eyJzdWIiOiJ4In0", "c2ln\x01bmF0dXJlLXZhbHVl")
t.Run("valid token passes twice and is cached", func(t *testing.T) {
for i := 0; i < 2; i++ {
if res := cm.validateToken(valid, AccessTokenConfig); res.Error != nil {
t.Fatalf("pass %d: %v", i, res.Error)
}
}
if _, ok := cm.cachedTokenTimes(contentValidKey(valid, AccessTokenConfig)); !ok {
t.Fatal("valid token was not cached")
}
})
t.Run("cache is per token type", func(t *testing.T) {
if _, ok := cm.cachedTokenTimes(contentValidKey(valid, IDTokenConfig)); ok {
t.Fatal("access-token entry leaked into the ID-token key")
}
})
t.Run("time checks still run on a cache hit", func(t *testing.T) {
cm.markContentValid(contentValidKey(futureIat, AccessTokenConfig), cm.tokenTimesOf(futureIat))
res := cm.validateToken(futureIat, AccessTokenConfig)
if res.Error == nil || !strings.Contains(res.Error.Error(), "issued in future") {
t.Fatalf("error = %v, want issued-in-future through the content cache", res.Error)
}
})
t.Run("cache hit uses the stored claims", func(t *testing.T) {
other := tokenWithGroups(t, map[string]interface{}{"sub": "c", "exp": float64(now.Add(time.Hour).Unix()), "iat": float64(now.Unix())})
future := now.Add(time.Hour)
cm.markContentValid(contentValidKey(other, AccessTokenConfig), tokenTimes{jwt: true, iat: &future})
res := cm.validateToken(other, AccessTokenConfig)
if res.Error == nil || !strings.Contains(res.Error.Error(), "issued in future") {
t.Fatalf("error = %v, want the cached iat to drive the freshness check", res.Error)
}
})
t.Run("invalid content never cached", func(t *testing.T) {
if res := cm.validateToken(badContent, AccessTokenConfig); res.Error == nil {
t.Fatal("control character accepted")
}
if _, ok := cm.cachedTokenTimes(contentValidKey(badContent, AccessTokenConfig)); ok {
t.Fatal("invalid token was cached")
}
})
t.Run("cache stays bounded", func(t *testing.T) {
for i := 0; i < contentValidCacheMax+10; i++ {
cm.markContentValid(contentValidKey(strings.Repeat("x", i%7)+string(rune('a'+i%26))+strings.Repeat("y", i/26), AccessTokenConfig), tokenTimes{})
}
cm.contentMu.Lock()
n := len(cm.contentValid)
cm.contentMu.Unlock()
if n > contentValidCacheMax {
t.Fatalf("cache size %d exceeds %d", n, contentValidCacheMax)
}
})
}
+16 -1
View File
@@ -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
+50
View File
@@ -0,0 +1,50 @@
package traefikoidc
import (
"net/http/httptest"
"testing"
"time"
)
func TestIDTokenMemoFollowsStoredToken(t *testing.T) {
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef", false, "", "", time.Hour, NewLogger("error"))
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
t.Cleanup(func() {
if err := sm.Shutdown(); err != nil {
t.Errorf("Shutdown: %v", err)
}
})
future := float64(time.Now().Add(time.Hour).Unix())
first := tokenWithGroups(t, map[string]interface{}{"sub": "first", "exp": future})
second := tokenWithGroups(t, map[string]interface{}{"sub": "second", "exp": future})
session, err := sm.GetSession(httptest.NewRequest("GET", "/", nil))
if err != nil {
t.Fatalf("GetSession: %v", err)
}
steps := []struct {
name string
write func()
want string
}{
{"first read", func() { session.SetIDToken(first) }, first},
{"repeat read", func() {}, first},
{"after overwrite", func() { session.SetIDToken(second) }, second},
{"after clearing", func() { session.SetIDToken("") }, ""},
{"after Reset", func() { session.SetIDToken(first); _ = session.GetIDToken(); session.Reset() }, ""},
}
for _, st := range steps {
st.write()
if got := session.GetIDToken(); got != st.want {
t.Fatalf("%s: GetIDToken = %q, want %q", st.name, got, st.want)
}
if st.name == "repeat read" && session.idTokenMemoRaw != first {
t.Fatal("repeat read did not go through the memo; the test no longer covers it")
}
}
if session.idTokenMemoRaw != "" || session.idTokenMemo != "" {
t.Fatal("Reset left an ID-token memo on the pooled session")
}
}
+14 -1
View File
@@ -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
View File
@@ -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)
+32 -7
View File
@@ -193,8 +193,10 @@ func compressCombinedPayload(payload *combinedSessionPayload) (string, error) {
}
// decompressCombinedPayload decompresses a base64+gzip encoded combined session payload.
// readers pools *gzip.Reader values: a new reader allocates its 32 KB window,
// which is a large per-request cost under yaegi.
// Returns the deserialized payload and any error encountered.
func decompressCombinedPayload(compressed string) (*combinedSessionPayload, error) {
func decompressCombinedPayload(compressed string, readers *sync.Pool) (*combinedSessionPayload, error) {
if compressed == "" {
return nil, fmt.Errorf("empty compressed data")
}
@@ -204,11 +206,16 @@ func decompressCombinedPayload(compressed string) (*combinedSessionPayload, erro
return nil, fmt.Errorf("failed to decode base64: %w", err)
}
gr, err := gzip.NewReader(bytes.NewReader(data))
gr, _ := readers.Get().(*gzip.Reader)
if gr == nil {
gr, err = gzip.NewReader(bytes.NewReader(data))
} else {
err = gr.Reset(bytes.NewReader(data))
}
if err != nil {
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
}
defer func() { _ = gr.Close() }()
defer readers.Put(gr)
// Limit decompressed size to prevent zip bombs
limitedReader := io.LimitReader(gr, 512*1024) // 512KB max
@@ -408,6 +415,7 @@ func decompressTokenInternal(compressed string) string {
// session object reuse and supports both HTTP and HTTPS schemes.
type SessionManager struct {
sessionPool sync.Pool
gzipReaders sync.Pool
ctx context.Context
store sessions.Store
logger *Logger
@@ -1265,7 +1273,7 @@ func (sm *SessionManager) loadFromCombinedCookies(r *http.Request, sessionData *
return false
}
payload, err := decompressCombinedPayload(compressed)
payload, err := decompressCombinedPayload(compressed, &sm.gzipReaders)
if err != nil {
sm.logger.Debugf("Failed to decompress combined payload: %v", err)
return false
@@ -1410,6 +1418,13 @@ type SessionData struct {
// timed against the new owner's own writes.
sessionOwner atomic.Uint64
// idTokenMemoRaw and idTokenMemo cache getIDTokenUnsafe's validated result
// for an unchunked, uncompressed stored token, keyed by the stored value.
// A request reads the ID token twice, and each read re-ran full chunk
// validation. Cleared by Reset so no result outlives its request.
idTokenMemoRaw string
idTokenMemo string
// cachedClaimsToken is the ID token string whose claims were last parsed and
// cached. A lazy, per-request cache to avoid re-parsing the JWT on every
// authenticated request (e.g. for headerTemplates). Protected by sessionMutex.
@@ -2086,6 +2101,8 @@ func (sd *SessionData) Reset() {
sd.cachedClaimsToken = ""
sd.cachedClaims = nil
sd.cachedClaimsErr = nil
sd.idTokenMemoRaw = ""
sd.idTokenMemo = ""
// Reset the refresh mutex to ensure clean state
// Note: We don't need to lock it since sessionMutex is already held
@@ -3017,6 +3034,11 @@ func (sd *SessionData) getIDTokenUnsafe() string {
return token
}
memoable := token != "" && !compressed && len(sd.idTokenChunks) == 0
if memoable && token == sd.idTokenMemoRaw {
return sd.idTokenMemo
}
result := sd.manager.chunkManager.GetToken(
token,
compressed,
@@ -3024,11 +3046,14 @@ func (sd *SessionData) getIDTokenUnsafe() string {
IDTokenConfig,
)
validated := result.Token
if result.Error != nil {
return ""
validated = ""
}
return result.Token
if memoable {
sd.idTokenMemoRaw, sd.idTokenMemo = token, validated
}
return validated
}
// getRefreshTokenUnsafe retrieves the refresh token without acquiring locks.
+121 -39
View File
@@ -10,6 +10,7 @@ import (
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/gorilla/sessions"
"github.com/lukaszraczylo/traefikoidc/internal/pool"
@@ -89,9 +90,14 @@ type TokenRetrievalResult struct {
// and error handling to ensure data integrity and prevent security vulnerabilities
// throughout the process.
type ChunkManager struct {
lastCleanup time.Time
ctx context.Context
mutex *sync.RWMutex
lastCleanup time.Time
ctx context.Context
mutex *sync.RWMutex
// contentValid holds keys of tokens that passed every content-only check.
// Browsers resend the same cookie, and re-running those checks on every
// request dominated per-request cost under yaegi. Guarded by contentMu.
contentValid map[string]tokenTimes
contentMu sync.Mutex
cancel context.CancelFunc
sessionMap map[string]*SessionEntry
logger *Logger
@@ -127,14 +133,15 @@ func NewChunkManager(logger *Logger) *ChunkManager {
ctx, cancel := context.WithCancel(context.Background())
cm := &ChunkManager{
logger: logger,
mutex: &sync.RWMutex{},
ctx: ctx,
cancel: cancel,
sessionMap: make(map[string]*SessionEntry),
maxSessions: 200, // CRITICAL FIX: Reduced from 1000 to 200 per instance
sessionTTL: 15 * time.Minute, // CRITICAL FIX: Reduced from 24h to 15 minutes
lastCleanup: time.Now(),
logger: logger,
mutex: &sync.RWMutex{},
ctx: ctx,
cancel: cancel,
sessionMap: make(map[string]*SessionEntry),
contentValid: make(map[string]tokenTimes),
maxSessions: 200, // CRITICAL FIX: Reduced from 1000 to 200 per instance
sessionTTL: 15 * time.Minute, // CRITICAL FIX: Reduced from 24h to 15 minutes
lastCleanup: time.Now(),
}
// Start background cleanup routine
@@ -293,26 +300,38 @@ func (cm *ChunkManager) processSingleToken(token string, compressed bool, config
// Returns:
// - TokenRetrievalResult with the validated token or validation error.
func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRetrievalResult {
if sizeErr := cm.validateTokenSize(token, config); sizeErr != nil {
return TokenRetrievalResult{Token: "", Error: sizeErr}
// Content-only checks depend on the token string alone, so a token that
// passed them once skips them. Expiry and freshness always run.
key := contentValidKey(token, config)
times, cached := cm.cachedTokenTimes(key)
if !cached {
if sizeErr := cm.validateTokenSize(token, config); sizeErr != nil {
return TokenRetrievalResult{Token: "", Error: sizeErr}
}
cm.validateChunkingEfficiency(token, config)
if contentErr := cm.validateTokenContent(token, config); contentErr != nil {
return TokenRetrievalResult{Token: "", Error: contentErr}
}
times = cm.tokenTimesOf(token)
}
cm.validateChunkingEfficiency(token, config)
if contentErr := cm.validateTokenContent(token, config); contentErr != nil {
return TokenRetrievalResult{Token: "", Error: contentErr}
}
if expErr := cm.validateTokenExpiration(token, config); expErr != nil {
if expErr := cm.validateTokenExpiration(times, config); expErr != nil {
return TokenRetrievalResult{Token: "", Error: expErr}
}
if freshnessErr := cm.validateTokenFreshness(token, config); freshnessErr != nil {
if freshnessErr := cm.validateTokenFreshness(times, config); freshnessErr != nil {
return TokenRetrievalResult{Token: "", Error: freshnessErr}
}
// Determine if token is opaque or JWT based on format
// JWT tokens have exactly 2 dots (3 parts: header.payload.signature)
if cached {
return TokenRetrievalResult{Token: token, Error: nil}
}
dotCount := strings.Count(token, ".")
isJWT := dotCount == 2
@@ -338,6 +357,7 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet
}
}
cm.markContentValid(key, times)
return TokenRetrievalResult{Token: token, Error: nil}
}
@@ -350,6 +370,53 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet
//
// Returns:
// - TokenRetrievalResult with the reassembled token or error.
//
// tokenTimes holds a token's parsed exp and iat claims. They are part of the
// token content, so a content-cache entry keeps them to avoid re-parsing the
// payload on every request. The checks that use them still run each time.
type tokenTimes struct {
exp, iat *time.Time
expErr, iatErr error
jwt bool
}
// tokenTimesOf parses exp and iat from a JWT-shaped token.
func (cm *ChunkManager) tokenTimesOf(token string) tokenTimes {
if !strings.Contains(token, ".") {
return tokenTimes{}
}
t := tokenTimes{jwt: true}
t.exp, t.expErr = cm.extractJWTExpiration(token)
t.iat, t.iatErr = cm.extractJWTIssuedAt(token)
return t
}
// contentValidCacheMax bounds contentValid; the map is cleared when full.
const contentValidCacheMax = 4096
// contentValidKey keys a token by type and SHA-256 so the cache does not
// retain token values.
func contentValidKey(token string, config TokenConfig) string {
sum := sha256.Sum256([]byte(token))
return config.Type + ":" + string(sum[:])
}
func (cm *ChunkManager) cachedTokenTimes(key string) (tokenTimes, bool) {
cm.contentMu.Lock()
t, ok := cm.contentValid[key]
cm.contentMu.Unlock()
return t, ok
}
func (cm *ChunkManager) markContentValid(key string, t tokenTimes) {
cm.contentMu.Lock()
if len(cm.contentValid) >= contentValidCacheMax {
cm.contentValid = make(map[string]tokenTimes)
}
cm.contentValid[key] = t
cm.contentMu.Unlock()
}
func (cm *ChunkManager) processChunkedToken(chunks map[int]*sessions.Session, config TokenConfig) TokenRetrievalResult {
if len(chunks) > config.MaxChunks {
err := fmt.Errorf("too many %s token chunks (%d, max: %d)", config.Type, len(chunks), config.MaxChunks)
@@ -504,7 +571,9 @@ func (cm *ChunkManager) validateJWTFormat(token string, tokenType string) error
return err
}
for _, char := range part {
// Byte loop: a multi-byte rune fails on its first byte either way.
for j := 0; j < len(part); j++ {
char := part[j]
if !((char >= 'A' && char <= 'Z') ||
(char >= 'a' && char <= 'z') ||
(char >= '0' && char <= '9') ||
@@ -717,9 +786,10 @@ func (cm *ChunkManager) validateTokenSanitization(token string, config TokenConf
return err
}
// Check for control characters (ASCII 0-31 and 127)
for i, char := range token {
if char < 32 || char == 127 {
// Check for control characters (ASCII 0-31 and 127). Byte loop: bytes of
// a multi-byte rune are >= 0x80, and i is a byte offset either way.
for i := 0; i < len(token); i++ {
if char := token[i]; char < 32 || char == 127 {
err := fmt.Errorf("%s token contains control character at position %d", config.Type, i)
return err
}
@@ -921,7 +991,13 @@ func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfi
currentRepeated := 1
var lastChar rune
for i, char := range token {
// Index loops, not range: under yaegi, ranging over a string allocates
// O(n²) bytes, which dominated per-request cost for 1-2 KB tokens.
for i := 0; i < len(token); {
char, size := rune(token[i]), 1
if char >= utf8.RuneSelf {
char, size = utf8.DecodeRuneInString(token[i:])
}
if i > 0 && char == lastChar {
currentRepeated++
if currentRepeated > maxRepeated {
@@ -931,6 +1007,7 @@ func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfi
currentRepeated = 1
}
lastChar = char
i += size
}
threshold := 20
@@ -941,8 +1018,13 @@ func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfi
}
charFreq := make(map[rune]int)
for _, char := range token {
for i := 0; i < len(token); {
char, size := rune(token[i]), 1
if char >= utf8.RuneSelf {
char, size = utf8.DecodeRuneInString(token[i:])
}
charFreq[char]++
i += size
}
tokenLen := len(token)
@@ -963,21 +1045,21 @@ func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfi
// It extracts and checks JWT expiration claims to ensure tokens are not expired
// and detects tokens with suspicious expiration times.
// Parameters:
// - token: The token to check expiration for.
// - times: The token's parsed claims (see tokenTimesOf).
// - config: Token configuration for error context.
//
// Returns:
// - An error if the token is expired or has invalid expiration, nil if valid.
//
//nolint:unparam // error return kept for API consistency and future use
func (cm *ChunkManager) validateTokenExpiration(token string, config TokenConfig) error {
if !strings.Contains(token, ".") {
func (cm *ChunkManager) validateTokenExpiration(times tokenTimes, config TokenConfig) error {
if !times.jwt {
return nil
}
expiration, err := cm.extractJWTExpiration(token)
if err != nil {
cm.logger.Debugf("Could not extract expiration from %s token: %v", config.Type, err)
expiration := times.exp
if times.expErr != nil {
cm.logger.Debugf("Could not extract expiration from %s token: %v", config.Type, times.expErr)
return nil
}
@@ -1062,19 +1144,19 @@ func (cm *ChunkManager) extractJWTExpiration(token string) (*time.Time, error) {
// It examines the 'iat' (issued at) claim to detect tokens issued too far
// in the future or suspiciously old tokens that might indicate replay attacks.
// Parameters:
// - token: The token to check freshness for.
// - times: The token's parsed claims (see tokenTimesOf).
// - config: Token configuration for error context.
//
// Returns:
// - An error if the token freshness is suspicious, nil if acceptable.
func (cm *ChunkManager) validateTokenFreshness(token string, config TokenConfig) error {
if !strings.Contains(token, ".") {
func (cm *ChunkManager) validateTokenFreshness(times tokenTimes, config TokenConfig) error {
if !times.jwt {
return nil
}
issuedAt, err := cm.extractJWTIssuedAt(token)
if err != nil {
cm.logger.Debugf("Could not extract issued time from %s token: %v", config.Type, err)
issuedAt := times.iat
if times.iatErr != nil {
cm.logger.Debugf("Could not extract issued time from %s token: %v", config.Type, times.iatErr)
return nil
}
+5 -2
View File
@@ -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
+286
View File
@@ -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
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.41"
const traefikoidcPluginVersion = "1.0.44"