mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-11-03 07:44:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a533ae2ef7 | ||
|
|
94a8664403 |
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+32
-7
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -14,4 +14,4 @@ const devPluginVersion = "0.0.0-dev"
|
||||
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph,
|
||||
// not the interpreted plugin). This build-stamped constant is therefore the
|
||||
// single source of truth for the version reported by anonymous usage telemetry.
|
||||
const traefikoidcPluginVersion = "1.0.41"
|
||||
const traefikoidcPluginVersion = "1.0.42"
|
||||
|
||||
Reference in New Issue
Block a user