mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-11-03 07:44:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dfab2a9fc |
@@ -0,0 +1,52 @@
|
|||||||
|
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,13 +72,6 @@ testData:
|
|||||||
value: "{{.Claims.email}}"
|
value: "{{.Claims.email}}"
|
||||||
- name: Authorization
|
- name: Authorization
|
||||||
value: "Bearer {{.AccessToken}}"
|
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)
|
# Security headers (default profile is enabled out of the box)
|
||||||
securityHeaders:
|
securityHeaders:
|
||||||
|
|||||||
@@ -441,34 +441,8 @@ headers:
|
|||||||
Rendering the whole context (`{{.}}`, `{{$}}`), the whole claims map
|
Rendering the whole context (`{{.}}`, `{{$}}`), the whole claims map
|
||||||
(`{{.Claims}}`), or any non-listed claim is rejected — this prevents a template
|
(`{{.Claims}}`), or any non-listed claim is rejected — this prevents a template
|
||||||
from accidentally forwarding raw tokens or unlisted claims. `range`/`with` must
|
from accidentally forwarding raw tokens or unlisted claims. `range`/`with` must
|
||||||
target a specific listed claim (e.g. `{{range .Claims.groups}}`); `get`, `default`
|
target a specific listed claim (e.g. `{{range .Claims.groups}}`); `get`/`default`
|
||||||
and `toJson` are the only functions allowed.
|
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
|
> **File-provider users: escape the braces.** Traefik's file provider runs
|
||||||
> every dynamic configuration file through Go templating before the plugin
|
> every dynamic configuration file through Go templating before the plugin
|
||||||
|
|||||||
+1
-4
@@ -223,9 +223,6 @@ func headerInjectionRuneReason(r rune) string {
|
|||||||
return ""
|
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
|
// 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
|
// header value, or "" if acceptable. It rejects values over maxLen (maxLen<=0
|
||||||
// disables the check) and values containing control or bidi-override runes, but
|
// disables the check) and values containing control or bidi-override runes, but
|
||||||
@@ -233,7 +230,7 @@ const headerReasonTooLong = "exceeds max length"
|
|||||||
// never includes the value, so it is safe to log.
|
// never includes the value, so it is safe to log.
|
||||||
func headerValueReason(value string, maxLen int) string {
|
func headerValueReason(value string, maxLen int) string {
|
||||||
if maxLen > 0 && len(value) > maxLen {
|
if maxLen > 0 && len(value) > maxLen {
|
||||||
return headerReasonTooLong
|
return "exceeds max length"
|
||||||
}
|
}
|
||||||
for _, r := range value {
|
for _, r := range value {
|
||||||
if reason := headerInjectionRuneReason(r); reason != "" {
|
if reason := headerInjectionRuneReason(r); reason != "" {
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -139,7 +139,6 @@ func main() {
|
|||||||
runCheck("middleware-erraborthandler-aborts-under-yaegi", checkErrAbortHandlerAbortsUnderYaegi)
|
runCheck("middleware-erraborthandler-aborts-under-yaegi", checkErrAbortHandlerAbortsUnderYaegi)
|
||||||
runCheck("fix17-setifabsent-claims-once", checkSetIfAbsentUnderYaegi)
|
runCheck("fix17-setifabsent-claims-once", checkSetIfAbsentUnderYaegi)
|
||||||
runCheck("issue162-websocket-forwards-header-templates", checkIssue162WebSocketHeaderTemplates)
|
runCheck("issue162-websocket-forwards-header-templates", checkIssue162WebSocketHeaderTemplates)
|
||||||
runCheck("tojson-header-template", checkToJsonHeaderTemplate)
|
|
||||||
runCheck("streaming-refresh-revoked-grant-rejected", checkStreamingRefreshRevokedGrant)
|
runCheck("streaming-refresh-revoked-grant-rejected", checkStreamingRefreshRevokedGrant)
|
||||||
fmt.Println("OK: all yaegi regression checks passed")
|
fmt.Println("OK: all yaegi regression checks passed")
|
||||||
}
|
}
|
||||||
@@ -808,99 +807,6 @@ func checkIssue162WebSocketHeaderTemplates() (string, error) {
|
|||||||
return "", nil
|
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
|
// checkStreamingRefreshRevokedGrant drives streamingRefresh under the
|
||||||
// interpreter through the real RefreshCoordinator and token exchanger: an
|
// interpreter through the real RefreshCoordinator and token exchanger: an
|
||||||
// expired session whose refresh the IdP answers with invalid_grant must get
|
// expired session whose refresh the IdP answers with invalid_grant must get
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+1
-16
@@ -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
|
`phone_number`, `email_verified`, `updated_at`; the authoritative list is
|
||||||
`safeClaimsFields` in `template_validation.go`). Rejected: rendering the whole
|
`safeClaimsFields` in `template_validation.go`). Rejected: rendering the whole
|
||||||
context (`{{.}}`, `{{$}}`) or the whole claims map (`{{.Claims}}`), any claim not
|
context (`{{.}}`, `{{$}}`) or the whole claims map (`{{.Claims}}`), any claim not
|
||||||
on the list, functions other than `get`/`default`/`toJson`, and `range`/`with` that do not
|
on the list, functions other than `get`/`default`, and `range`/`with` that do not
|
||||||
target a specific listed claim (e.g. `{{range .Claims}}` is rejected; `{{range
|
target a specific listed claim (e.g. `{{range .Claims}}` is rejected; `{{range
|
||||||
.Claims.groups}}` is allowed).
|
.Claims.groups}}` is allowed).
|
||||||
|
|
||||||
@@ -592,21 +592,6 @@ Listed names apply to both direct access (`{{.Claims.employee_id}}`) and
|
|||||||
claim (e.g. `realm_access.roles`) are already accessible without listing them
|
claim (e.g. `realm_access.roles`) are already accessible without listing them
|
||||||
individually.
|
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
|
## Security Headers
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -86,10 +85,8 @@ var defaultExcludedURLs = map[string]struct{}{
|
|||||||
|
|
||||||
// headerTemplateFuncMap returns the function map available to custom header
|
// headerTemplateFuncMap returns the function map available to custom header
|
||||||
// value templates, gated by the effective claims whitelist (built-in +
|
// value templates, gated by the effective claims whitelist (built-in +
|
||||||
// config.AllowedClaims). It exposes exactly three helpers:
|
// config.AllowedClaims). It exposes exactly two helpers:
|
||||||
// - default: substitute a fallback when a value is nil/empty.
|
// - 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
|
// - 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
|
// 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
|
// (issue #149 review). This is the sole runtime enforcement of the claims
|
||||||
@@ -105,16 +102,6 @@ func headerTemplateFuncMap(allowedClaims map[string]bool) template.FuncMap {
|
|||||||
}
|
}
|
||||||
return val
|
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{} {
|
"get": func(m interface{}, key string) interface{} {
|
||||||
if !allowedClaims[key] {
|
if !allowedClaims[key] {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
+1
-5
@@ -1371,11 +1371,7 @@ func (t *TraefikOidc) applyHeaderTemplates(req *http.Request, p *principal) {
|
|||||||
// forwarding a tainted value. Do not log the value (it commonly
|
// forwarding a tainted value. Do not log the value (it commonly
|
||||||
// carries the access token); log only name + reason.
|
// carries the access token); log only name + reason.
|
||||||
if reason := headerValueReason(headerValue, headerTemplateMaxLen); reason != "" {
|
if reason := headerValueReason(headerValue, headerTemplateMaxLen); reason != "" {
|
||||||
if reason == headerReasonTooLong {
|
t.logger.Debugf("Dropping templated header %s: value failed sanitization (%s)", headerName, reason)
|
||||||
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
|
continue
|
||||||
}
|
}
|
||||||
req.Header.Set(headerName, headerValue)
|
req.Header.Set(headerName, headerValue)
|
||||||
|
|||||||
+7
-32
@@ -193,10 +193,8 @@ func compressCombinedPayload(payload *combinedSessionPayload) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// decompressCombinedPayload decompresses a base64+gzip encoded combined session payload.
|
// 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.
|
// Returns the deserialized payload and any error encountered.
|
||||||
func decompressCombinedPayload(compressed string, readers *sync.Pool) (*combinedSessionPayload, error) {
|
func decompressCombinedPayload(compressed string) (*combinedSessionPayload, error) {
|
||||||
if compressed == "" {
|
if compressed == "" {
|
||||||
return nil, fmt.Errorf("empty compressed data")
|
return nil, fmt.Errorf("empty compressed data")
|
||||||
}
|
}
|
||||||
@@ -206,16 +204,11 @@ func decompressCombinedPayload(compressed string, readers *sync.Pool) (*combined
|
|||||||
return nil, fmt.Errorf("failed to decode base64: %w", err)
|
return nil, fmt.Errorf("failed to decode base64: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
gr, _ := readers.Get().(*gzip.Reader)
|
gr, err := gzip.NewReader(bytes.NewReader(data))
|
||||||
if gr == nil {
|
|
||||||
gr, err = gzip.NewReader(bytes.NewReader(data))
|
|
||||||
} else {
|
|
||||||
err = gr.Reset(bytes.NewReader(data))
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
|
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
|
||||||
}
|
}
|
||||||
defer readers.Put(gr)
|
defer func() { _ = gr.Close() }()
|
||||||
|
|
||||||
// Limit decompressed size to prevent zip bombs
|
// Limit decompressed size to prevent zip bombs
|
||||||
limitedReader := io.LimitReader(gr, 512*1024) // 512KB max
|
limitedReader := io.LimitReader(gr, 512*1024) // 512KB max
|
||||||
@@ -415,7 +408,6 @@ func decompressTokenInternal(compressed string) string {
|
|||||||
// session object reuse and supports both HTTP and HTTPS schemes.
|
// session object reuse and supports both HTTP and HTTPS schemes.
|
||||||
type SessionManager struct {
|
type SessionManager struct {
|
||||||
sessionPool sync.Pool
|
sessionPool sync.Pool
|
||||||
gzipReaders sync.Pool
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
store sessions.Store
|
store sessions.Store
|
||||||
logger *Logger
|
logger *Logger
|
||||||
@@ -1273,7 +1265,7 @@ func (sm *SessionManager) loadFromCombinedCookies(r *http.Request, sessionData *
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, err := decompressCombinedPayload(compressed, &sm.gzipReaders)
|
payload, err := decompressCombinedPayload(compressed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sm.logger.Debugf("Failed to decompress combined payload: %v", err)
|
sm.logger.Debugf("Failed to decompress combined payload: %v", err)
|
||||||
return false
|
return false
|
||||||
@@ -1418,13 +1410,6 @@ type SessionData struct {
|
|||||||
// timed against the new owner's own writes.
|
// timed against the new owner's own writes.
|
||||||
sessionOwner atomic.Uint64
|
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
|
// 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
|
// cached. A lazy, per-request cache to avoid re-parsing the JWT on every
|
||||||
// authenticated request (e.g. for headerTemplates). Protected by sessionMutex.
|
// authenticated request (e.g. for headerTemplates). Protected by sessionMutex.
|
||||||
@@ -2101,8 +2086,6 @@ func (sd *SessionData) Reset() {
|
|||||||
sd.cachedClaimsToken = ""
|
sd.cachedClaimsToken = ""
|
||||||
sd.cachedClaims = nil
|
sd.cachedClaims = nil
|
||||||
sd.cachedClaimsErr = nil
|
sd.cachedClaimsErr = nil
|
||||||
sd.idTokenMemoRaw = ""
|
|
||||||
sd.idTokenMemo = ""
|
|
||||||
|
|
||||||
// Reset the refresh mutex to ensure clean state
|
// Reset the refresh mutex to ensure clean state
|
||||||
// Note: We don't need to lock it since sessionMutex is already held
|
// Note: We don't need to lock it since sessionMutex is already held
|
||||||
@@ -3034,11 +3017,6 @@ func (sd *SessionData) getIDTokenUnsafe() string {
|
|||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
memoable := token != "" && !compressed && len(sd.idTokenChunks) == 0
|
|
||||||
if memoable && token == sd.idTokenMemoRaw {
|
|
||||||
return sd.idTokenMemo
|
|
||||||
}
|
|
||||||
|
|
||||||
result := sd.manager.chunkManager.GetToken(
|
result := sd.manager.chunkManager.GetToken(
|
||||||
token,
|
token,
|
||||||
compressed,
|
compressed,
|
||||||
@@ -3046,14 +3024,11 @@ func (sd *SessionData) getIDTokenUnsafe() string {
|
|||||||
IDTokenConfig,
|
IDTokenConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
validated := result.Token
|
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
validated = ""
|
return ""
|
||||||
}
|
}
|
||||||
if memoable {
|
|
||||||
sd.idTokenMemoRaw, sd.idTokenMemo = token, validated
|
return result.Token
|
||||||
}
|
|
||||||
return validated
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRefreshTokenUnsafe retrieves the refresh token without acquiring locks.
|
// getRefreshTokenUnsafe retrieves the refresh token without acquiring locks.
|
||||||
|
|||||||
+39
-121
@@ -10,7 +10,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
"github.com/lukaszraczylo/traefikoidc/internal/pool"
|
"github.com/lukaszraczylo/traefikoidc/internal/pool"
|
||||||
@@ -90,14 +89,9 @@ type TokenRetrievalResult struct {
|
|||||||
// and error handling to ensure data integrity and prevent security vulnerabilities
|
// and error handling to ensure data integrity and prevent security vulnerabilities
|
||||||
// throughout the process.
|
// throughout the process.
|
||||||
type ChunkManager struct {
|
type ChunkManager struct {
|
||||||
lastCleanup time.Time
|
lastCleanup time.Time
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
mutex *sync.RWMutex
|
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
|
cancel context.CancelFunc
|
||||||
sessionMap map[string]*SessionEntry
|
sessionMap map[string]*SessionEntry
|
||||||
logger *Logger
|
logger *Logger
|
||||||
@@ -133,15 +127,14 @@ func NewChunkManager(logger *Logger) *ChunkManager {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
cm := &ChunkManager{
|
cm := &ChunkManager{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
mutex: &sync.RWMutex{},
|
mutex: &sync.RWMutex{},
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
sessionMap: make(map[string]*SessionEntry),
|
sessionMap: make(map[string]*SessionEntry),
|
||||||
contentValid: make(map[string]tokenTimes),
|
maxSessions: 200, // CRITICAL FIX: Reduced from 1000 to 200 per instance
|
||||||
maxSessions: 200, // CRITICAL FIX: Reduced from 1000 to 200 per instance
|
sessionTTL: 15 * time.Minute, // CRITICAL FIX: Reduced from 24h to 15 minutes
|
||||||
sessionTTL: 15 * time.Minute, // CRITICAL FIX: Reduced from 24h to 15 minutes
|
lastCleanup: time.Now(),
|
||||||
lastCleanup: time.Now(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start background cleanup routine
|
// Start background cleanup routine
|
||||||
@@ -300,38 +293,26 @@ func (cm *ChunkManager) processSingleToken(token string, compressed bool, config
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - TokenRetrievalResult with the validated token or validation error.
|
// - TokenRetrievalResult with the validated token or validation error.
|
||||||
func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRetrievalResult {
|
func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRetrievalResult {
|
||||||
// Content-only checks depend on the token string alone, so a token that
|
if sizeErr := cm.validateTokenSize(token, config); sizeErr != nil {
|
||||||
// passed them once skips them. Expiry and freshness always run.
|
return TokenRetrievalResult{Token: "", Error: sizeErr}
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if expErr := cm.validateTokenExpiration(times, config); expErr != nil {
|
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 {
|
||||||
return TokenRetrievalResult{Token: "", Error: expErr}
|
return TokenRetrievalResult{Token: "", Error: expErr}
|
||||||
}
|
}
|
||||||
|
|
||||||
if freshnessErr := cm.validateTokenFreshness(times, config); freshnessErr != nil {
|
if freshnessErr := cm.validateTokenFreshness(token, config); freshnessErr != nil {
|
||||||
return TokenRetrievalResult{Token: "", Error: freshnessErr}
|
return TokenRetrievalResult{Token: "", Error: freshnessErr}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if token is opaque or JWT based on format
|
// Determine if token is opaque or JWT based on format
|
||||||
// JWT tokens have exactly 2 dots (3 parts: header.payload.signature)
|
// JWT tokens have exactly 2 dots (3 parts: header.payload.signature)
|
||||||
if cached {
|
|
||||||
return TokenRetrievalResult{Token: token, Error: nil}
|
|
||||||
}
|
|
||||||
|
|
||||||
dotCount := strings.Count(token, ".")
|
dotCount := strings.Count(token, ".")
|
||||||
isJWT := dotCount == 2
|
isJWT := dotCount == 2
|
||||||
|
|
||||||
@@ -357,7 +338,6 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cm.markContentValid(key, times)
|
|
||||||
return TokenRetrievalResult{Token: token, Error: nil}
|
return TokenRetrievalResult{Token: token, Error: nil}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,53 +350,6 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet
|
|||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - TokenRetrievalResult with the reassembled token or error.
|
// - 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 {
|
func (cm *ChunkManager) processChunkedToken(chunks map[int]*sessions.Session, config TokenConfig) TokenRetrievalResult {
|
||||||
if len(chunks) > config.MaxChunks {
|
if len(chunks) > config.MaxChunks {
|
||||||
err := fmt.Errorf("too many %s token chunks (%d, max: %d)", config.Type, len(chunks), config.MaxChunks)
|
err := fmt.Errorf("too many %s token chunks (%d, max: %d)", config.Type, len(chunks), config.MaxChunks)
|
||||||
@@ -571,9 +504,7 @@ func (cm *ChunkManager) validateJWTFormat(token string, tokenType string) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Byte loop: a multi-byte rune fails on its first byte either way.
|
for _, char := range part {
|
||||||
for j := 0; j < len(part); j++ {
|
|
||||||
char := part[j]
|
|
||||||
if !((char >= 'A' && char <= 'Z') ||
|
if !((char >= 'A' && char <= 'Z') ||
|
||||||
(char >= 'a' && char <= 'z') ||
|
(char >= 'a' && char <= 'z') ||
|
||||||
(char >= '0' && char <= '9') ||
|
(char >= '0' && char <= '9') ||
|
||||||
@@ -786,10 +717,9 @@ func (cm *ChunkManager) validateTokenSanitization(token string, config TokenConf
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for control characters (ASCII 0-31 and 127). Byte loop: bytes of
|
// Check for control characters (ASCII 0-31 and 127)
|
||||||
// a multi-byte rune are >= 0x80, and i is a byte offset either way.
|
for i, char := range token {
|
||||||
for i := 0; i < len(token); i++ {
|
if char < 32 || char == 127 {
|
||||||
if char := token[i]; char < 32 || char == 127 {
|
|
||||||
err := fmt.Errorf("%s token contains control character at position %d", config.Type, i)
|
err := fmt.Errorf("%s token contains control character at position %d", config.Type, i)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -991,13 +921,7 @@ func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfi
|
|||||||
currentRepeated := 1
|
currentRepeated := 1
|
||||||
var lastChar rune
|
var lastChar rune
|
||||||
|
|
||||||
// Index loops, not range: under yaegi, ranging over a string allocates
|
for i, char := range token {
|
||||||
// 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 {
|
if i > 0 && char == lastChar {
|
||||||
currentRepeated++
|
currentRepeated++
|
||||||
if currentRepeated > maxRepeated {
|
if currentRepeated > maxRepeated {
|
||||||
@@ -1007,7 +931,6 @@ func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfi
|
|||||||
currentRepeated = 1
|
currentRepeated = 1
|
||||||
}
|
}
|
||||||
lastChar = char
|
lastChar = char
|
||||||
i += size
|
|
||||||
}
|
}
|
||||||
|
|
||||||
threshold := 20
|
threshold := 20
|
||||||
@@ -1018,13 +941,8 @@ func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfi
|
|||||||
}
|
}
|
||||||
|
|
||||||
charFreq := make(map[rune]int)
|
charFreq := make(map[rune]int)
|
||||||
for i := 0; i < len(token); {
|
for _, char := range token {
|
||||||
char, size := rune(token[i]), 1
|
|
||||||
if char >= utf8.RuneSelf {
|
|
||||||
char, size = utf8.DecodeRuneInString(token[i:])
|
|
||||||
}
|
|
||||||
charFreq[char]++
|
charFreq[char]++
|
||||||
i += size
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenLen := len(token)
|
tokenLen := len(token)
|
||||||
@@ -1045,21 +963,21 @@ func (cm *ChunkManager) detectRepeatedCharacters(token string, config TokenConfi
|
|||||||
// It extracts and checks JWT expiration claims to ensure tokens are not expired
|
// It extracts and checks JWT expiration claims to ensure tokens are not expired
|
||||||
// and detects tokens with suspicious expiration times.
|
// and detects tokens with suspicious expiration times.
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - times: The token's parsed claims (see tokenTimesOf).
|
// - token: The token to check expiration for.
|
||||||
// - config: Token configuration for error context.
|
// - config: Token configuration for error context.
|
||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - An error if the token is expired or has invalid expiration, nil if valid.
|
// - 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
|
//nolint:unparam // error return kept for API consistency and future use
|
||||||
func (cm *ChunkManager) validateTokenExpiration(times tokenTimes, config TokenConfig) error {
|
func (cm *ChunkManager) validateTokenExpiration(token string, config TokenConfig) error {
|
||||||
if !times.jwt {
|
if !strings.Contains(token, ".") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
expiration := times.exp
|
expiration, err := cm.extractJWTExpiration(token)
|
||||||
if times.expErr != nil {
|
if err != nil {
|
||||||
cm.logger.Debugf("Could not extract expiration from %s token: %v", config.Type, times.expErr)
|
cm.logger.Debugf("Could not extract expiration from %s token: %v", config.Type, err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1144,19 +1062,19 @@ func (cm *ChunkManager) extractJWTExpiration(token string) (*time.Time, error) {
|
|||||||
// It examines the 'iat' (issued at) claim to detect tokens issued too far
|
// 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.
|
// in the future or suspiciously old tokens that might indicate replay attacks.
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - times: The token's parsed claims (see tokenTimesOf).
|
// - token: The token to check freshness for.
|
||||||
// - config: Token configuration for error context.
|
// - config: Token configuration for error context.
|
||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
// - An error if the token freshness is suspicious, nil if acceptable.
|
// - An error if the token freshness is suspicious, nil if acceptable.
|
||||||
func (cm *ChunkManager) validateTokenFreshness(times tokenTimes, config TokenConfig) error {
|
func (cm *ChunkManager) validateTokenFreshness(token string, config TokenConfig) error {
|
||||||
if !times.jwt {
|
if !strings.Contains(token, ".") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
issuedAt := times.iat
|
issuedAt, err := cm.extractJWTIssuedAt(token)
|
||||||
if times.iatErr != nil {
|
if err != nil {
|
||||||
cm.logger.Debugf("Could not extract issued time from %s token: %v", config.Type, times.iatErr)
|
cm.logger.Debugf("Could not extract issued time from %s token: %v", config.Type, err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ var allowedRootTokenFields = map[string]bool{
|
|||||||
var validateTemplateParseFuncs = map[string]any{
|
var validateTemplateParseFuncs = map[string]any{
|
||||||
"get": struct{}{},
|
"get": struct{}{},
|
||||||
"default": struct{}{},
|
"default": struct{}{},
|
||||||
"toJson": struct{}{},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// claimsWhitelist returns the effective set of claim field names a header
|
// claimsWhitelist returns the effective set of claim field names a header
|
||||||
@@ -184,14 +183,12 @@ func (v *templateValidator) validateCommand(cmd *parse.CommandNode, dotIsRoot bo
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateFunctionCall allows only get/default/toJson. get's map argument must be a
|
// validateFunctionCall allows only get/default. get's map argument must be a
|
||||||
// claims reference (never the root) and default's arguments are validated as
|
// claims reference (never the root) and default's arguments are validated as
|
||||||
// values; the runtime get additionally enforces the key whitelist.
|
// values; the runtime get additionally enforces the key whitelist.
|
||||||
func (v *templateValidator) validateFunctionCall(name string, args []parse.Node, dotIsRoot bool) error {
|
func (v *templateValidator) validateFunctionCall(name string, args []parse.Node, dotIsRoot bool) error {
|
||||||
switch name {
|
switch name {
|
||||||
case "default", "toJson":
|
case "default":
|
||||||
// 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 {
|
for _, arg := range args {
|
||||||
if err := v.validateValue(arg, dotIsRoot); err != nil {
|
if err := v.validateValue(arg, dotIsRoot); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,286 +0,0 @@
|
|||||||
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,
|
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph,
|
||||||
// not the interpreted plugin). This build-stamped constant is therefore the
|
// not the interpreted plugin). This build-stamped constant is therefore the
|
||||||
// single source of truth for the version reported by anonymous usage telemetry.
|
// single source of truth for the version reported by anonymous usage telemetry.
|
||||||
const traefikoidcPluginVersion = "1.0.44"
|
const traefikoidcPluginVersion = "1.0.41"
|
||||||
|
|||||||
Reference in New Issue
Block a user