Compare commits

..
1 Commits
Author SHA1 Message Date
github-actions[bot] 230cd3a4fe chore(release): v1.0.62 2026-11-03 01:37:56 +00:00
4 changed files with 309 additions and 101 deletions
+78 -1
View File
@@ -921,6 +921,82 @@ func TestMemoryTestOrchestrator_StopLeakDetection_CoverageBoost(t *testing.T) {
// CHUNK MANAGER TESTS // CHUNK MANAGER TESTS
// ============================================================================= // =============================================================================
func TestChunkManager_GetSessionCount_CoverageBoost(t *testing.T) {
logger := NewLogger("debug")
cm := NewChunkManager(logger)
if cm == nil {
t.Fatal("Expected non-nil ChunkManager")
}
defer cm.Shutdown()
// Test GetSessionCount
count := cm.GetSessionCount()
if count != 0 {
t.Errorf("Expected 0 sessions, got %d", count)
}
}
func TestChunkManager_GetMemoryStats_CoverageBoost(t *testing.T) {
logger := NewLogger("debug")
cm := NewChunkManager(logger)
if cm == nil {
t.Fatal("Expected non-nil ChunkManager")
}
defer cm.Shutdown()
// Test GetMemoryStats
stats := cm.GetMemoryStats()
if stats == nil {
t.Fatal("Expected non-nil stats")
}
// Verify expected keys exist
if _, ok := stats["active_sessions"]; !ok {
t.Error("Expected active_sessions key in stats")
}
if _, ok := stats["max_sessions"]; !ok {
t.Error("Expected max_sessions key in stats")
}
if _, ok := stats["bytes_allocated"]; !ok {
t.Error("Expected bytes_allocated key in stats")
}
}
func TestChunkManager_CanCreateSession_CoverageBoost(t *testing.T) {
logger := NewLogger("debug")
cm := NewChunkManager(logger)
if cm == nil {
t.Fatal("Expected non-nil ChunkManager")
}
defer cm.Shutdown()
// Test CanCreateSession - should be true initially
canCreate, err := cm.CanCreateSession()
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if !canCreate {
t.Error("Expected CanCreateSession to return true when empty")
}
}
func TestChunkManager_EmergencyCleanup_CoverageBoost(t *testing.T) {
logger := NewLogger("debug")
cm := NewChunkManager(logger)
if cm == nil {
t.Fatal("Expected non-nil ChunkManager")
}
defer cm.Shutdown()
// Test EmergencyCleanup - should not panic on empty session map
cm.EmergencyCleanup()
// Verify no sessions exist
if cm.GetSessionCount() != 0 {
t.Error("Expected 0 sessions after cleanup")
}
}
func TestChunkManager_CleanupExpiredSessions_CoverageBoost(t *testing.T) { func TestChunkManager_CleanupExpiredSessions_CoverageBoost(t *testing.T) {
logger := NewLogger("debug") logger := NewLogger("debug")
cm := NewChunkManager(logger) cm := NewChunkManager(logger)
@@ -932,7 +1008,8 @@ func TestChunkManager_CleanupExpiredSessions_CoverageBoost(t *testing.T) {
// Test CleanupExpiredSessions - should not panic on empty session map // Test CleanupExpiredSessions - should not panic on empty session map
cm.CleanupExpiredSessions() cm.CleanupExpiredSessions()
if len(cm.sessionMap) != 0 { // Verify no sessions exist
if cm.GetSessionCount() != 0 {
t.Error("Expected 0 sessions after cleanup") t.Error("Expected 0 sessions after cleanup")
} }
} }
-54
View File
@@ -1,54 +0,0 @@
package traefikoidc
import (
"strings"
"testing"
"time"
)
// Entra ID refresh tokens start with "1." and contain two dots, but are not JWTs.
func TestRefreshTokenWithDotsIsOpaque(t *testing.T) {
cm := NewChunkManager(NewLogger(""))
entra := "1.AXwAbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-" +
"." + strings.Repeat("Ab9_-xYz+/", 40) +
"." + strings.Repeat("Qw3rTy7u!~", 40)
t.Run("refresh config accepts dotted opaque token", func(t *testing.T) {
if res := cm.validateToken(entra, RefreshTokenConfig); res.Error != nil {
t.Fatalf("refresh token rejected: %v", res.Error)
}
})
t.Run("access config still validates dotted token as JWT", func(t *testing.T) {
res := cm.validateToken(entra, AccessTokenConfig)
if res.Error == nil {
t.Fatal("malformed JWT-shaped access token was accepted")
}
})
t.Run("refresh config still rejects control characters", func(t *testing.T) {
bad := strings.Replace(entra, "AXwA", "AX\x01A", 1)
if res := cm.validateToken(bad, RefreshTokenConfig); res.Error == nil {
t.Fatal("refresh token with control character was accepted")
}
})
t.Run("valid JWT refresh token keeps JWT validation", func(t *testing.T) {
future := time.Now().Add(time.Hour).Unix()
jwt := tokenWithGroups(t, map[string]interface{}{"sub": "a", "exp": float64(future + 3600), "iat": float64(future)})
res := cm.validateToken(jwt, RefreshTokenConfig)
if res.Error == nil || !strings.Contains(res.Error.Error(), "issued in future") {
t.Fatalf("error = %v, want issued-in-future from the JWT path", res.Error)
}
})
t.Run("malformed JWT refresh token stays rejected", func(t *testing.T) {
future := time.Now().Add(time.Hour).Unix()
jwt := tokenWithGroups(t, map[string]interface{}{"sub": "a", "exp": float64(future + 3600), "iat": float64(time.Now().Unix())})
parts := strings.Split(jwt, ".")
corrupt := parts[0] + "." + parts[1] + ".!!!not-base64!!!"
if res := cm.validateToken(corrupt, RefreshTokenConfig); res.Error == nil {
t.Fatal("JWT with a corrupt signature was accepted as opaque")
}
})
}
+230 -45
View File
@@ -5,7 +5,6 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
@@ -28,43 +27,12 @@ type TokenConfig struct {
MaxChunkSize int MaxChunkSize int
AllowOpaqueTokens bool AllowOpaqueTokens bool
RequireJWTFormat bool RequireJWTFormat bool
// AlwaysOpaque skips JWT checks even when the value contains dots.
AlwaysOpaque bool
// FallbackToOpaque validates a dotted token as opaque when it fails JWT
// validation. Entra ID refresh tokens have two dots but are not JWTs.
FallbackToOpaque bool
}
// claimsJWT reports whether the first segment decodes to a JSON header with an
// "alg" member. A token that does so claims to be a JWT, so a malformed one
// must stay rejected rather than fall back to opaque validation.
func claimsJWT(token string) bool {
seg, _, _ := strings.Cut(token, ".")
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(seg, "="))
if err != nil {
return false
}
var header map[string]any
if json.Unmarshal(raw, &header) != nil {
return false
}
_, ok := header["alg"]
return ok
}
// jwtShaped reports whether token must pass JWT validation.
func (c TokenConfig) jwtShaped(token string) bool {
return c.RequireJWTFormat || (c.AllowOpaqueTokens && !c.AlwaysOpaque && strings.Contains(token, "."))
}
// opaqueShaped reports whether token must pass opaque-token validation.
func (c TokenConfig) opaqueShaped(token string) bool {
return c.AllowOpaqueTokens && (c.AlwaysOpaque || !strings.Contains(token, "."))
} }
// Global session tracking to prevent memory leaks across all instances // Global session tracking to prevent memory leaks across all instances
var ( var (
globalSessionCount int64 = 0 globalSessionCount int64 = 0
globalMaxSessions int64 = 5000 // CRITICAL FIX: Global limit of 5000 total sessions
) )
// ResetGlobalSessionCounters resets global session tracking for testing // ResetGlobalSessionCounters resets global session tracking for testing
@@ -96,7 +64,6 @@ var (
MaxChunkSize: maxCookieSize, MaxChunkSize: maxCookieSize,
AllowOpaqueTokens: true, AllowOpaqueTokens: true,
RequireJWTFormat: false, RequireJWTFormat: false,
FallbackToOpaque: true,
} }
IDTokenConfig = TokenConfig{ IDTokenConfig = TokenConfig{
@@ -333,9 +300,6 @@ 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 {
if config.FallbackToOpaque && strings.Contains(token, ".") && !claimsJWT(token) {
config.AlwaysOpaque = true
}
// Content-only checks depend on the token string alone, so a token that // Content-only checks depend on the token string alone, so a token that
// passed them once skips them. Expiry and freshness always run. // passed them once skips them. Expiry and freshness always run.
key := contentValidKey(token, config) key := contentValidKey(token, config)
@@ -351,9 +315,7 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet
if contentErr := cm.validateTokenContent(token, config); contentErr != nil { if contentErr := cm.validateTokenContent(token, config); contentErr != nil {
return TokenRetrievalResult{Token: "", Error: contentErr} return TokenRetrievalResult{Token: "", Error: contentErr}
} }
if !config.AlwaysOpaque { times = cm.tokenTimesOf(token)
times = cm.tokenTimesOf(token)
}
} }
if expErr := cm.validateTokenExpiration(times, config); expErr != nil { if expErr := cm.validateTokenExpiration(times, config); expErr != nil {
@@ -370,7 +332,8 @@ func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRet
return TokenRetrievalResult{Token: token, Error: nil} return TokenRetrievalResult{Token: token, Error: nil}
} }
isJWT := strings.Count(token, ".") == 2 && !config.AlwaysOpaque dotCount := strings.Count(token, ".")
isJWT := dotCount == 2
if config.RequireJWTFormat && !config.AllowOpaqueTokens { if config.RequireJWTFormat && !config.AllowOpaqueTokens {
// Only accept JWT format tokens // Only accept JWT format tokens
@@ -722,7 +685,7 @@ func (cm *ChunkManager) validateTokenSize(token string, config TokenConfig) erro
return err return err
} }
if config.jwtShaped(token) { if config.RequireJWTFormat || (config.AllowOpaqueTokens && strings.Contains(token, ".")) {
parts := strings.Split(token, ".") parts := strings.Split(token, ".")
if len(parts) == 3 { if len(parts) == 3 {
headerLen := len(parts[0]) headerLen := len(parts[0])
@@ -746,7 +709,7 @@ func (cm *ChunkManager) validateTokenSize(token string, config TokenConfig) erro
} }
} }
if config.opaqueShaped(token) { if config.AllowOpaqueTokens && !strings.Contains(token, ".") {
if tokenLen > config.MaxLength { if tokenLen > config.MaxLength {
err := fmt.Errorf("%s opaque token unusually large (%d bytes)", config.Type, tokenLen) err := fmt.Errorf("%s opaque token unusually large (%d bytes)", config.Type, tokenLen)
return err return err
@@ -789,13 +752,13 @@ func (cm *ChunkManager) validateTokenContent(token string, config TokenConfig) e
return err return err
} }
if config.jwtShaped(token) { if config.RequireJWTFormat || (config.AllowOpaqueTokens && strings.Contains(token, ".")) {
if err := cm.validateJWTContent(token, config); err != nil { if err := cm.validateJWTContent(token, config); err != nil {
return err return err
} }
} }
if config.opaqueShaped(token) { if config.AllowOpaqueTokens && !strings.Contains(token, ".") {
if err := cm.validateOpaqueTokenContent(token, config); err != nil { if err := cm.validateOpaqueTokenContent(token, config); err != nil {
return err return err
} }
@@ -1280,4 +1243,226 @@ func (cm *ChunkManager) CleanupExpiredSessions(force ...bool) {
cm.logger.Debugf("Cleaned up %d expired sessions, freed %d bytes", cm.logger.Debugf("Cleaned up %d expired sessions, freed %d bytes",
len(expiredKeys), totalBytesFreed) len(expiredKeys), totalBytesFreed)
} }
// Enforce max sessions limit
if len(cm.sessionMap) > cm.maxSessions {
cm.enforceSessionLimit()
}
}
// enforceSessionLimit removes oldest sessions when limit is exceeded
// Uses partial sort (O(n) for finding k smallest) instead of full sort (O(n log n))
func (cm *ChunkManager) enforceSessionLimit() {
currentLocal := len(cm.sessionMap)
currentGlobal := atomic.LoadInt64(&globalSessionCount)
// CRITICAL FIX: Aggressive eviction when approaching limits
shouldEvict := false
targetCapacity := cm.maxSessions
// Check global limit first (more critical)
if currentGlobal >= globalMaxSessions {
shouldEvict = true
targetCapacity = cm.maxSessions / 4 // Aggressive reduction to 25%
} else if currentGlobal >= globalMaxSessions*8/10 { // 80% of global
shouldEvict = true
targetCapacity = cm.maxSessions / 2 // Reduce to 50%
} else if currentLocal >= cm.maxSessions {
shouldEvict = true
targetCapacity = cm.maxSessions * 3 / 4 // Reduce to 75%
}
if !shouldEvict {
return
}
// Calculate how many sessions to remove
excessCount := currentLocal - targetCapacity
if excessCount <= 0 {
return
}
// Use partial selection instead of full sort for better performance
// For finding k oldest sessions, we only need O(n) operations
keysToRemove := cm.findOldestSessions(excessCount)
totalBytesFreed := int64(0)
removedCount := int64(0)
for _, key := range keysToRemove {
if entry, exists := cm.sessionMap[key]; exists {
totalBytesFreed += entry.SizeEstimate
atomic.AddInt64(&cm.bytesAllocated, -entry.SizeEstimate)
removedCount++
}
delete(cm.sessionMap, key)
}
// Update global count
if removedCount > 0 {
atomic.AddInt64(&globalSessionCount, -removedCount)
}
cm.logger.Infof("Enforced session limit: removed %d excess sessions, freed %d bytes",
len(keysToRemove), totalBytesFreed)
}
// findOldestSessions returns keys of the k oldest sessions efficiently
// Uses a simple approach: find the kth oldest timestamp, then collect all older entries
func (cm *ChunkManager) findOldestSessions(k int) []string {
if k <= 0 || len(cm.sessionMap) == 0 {
return nil
}
if k >= len(cm.sessionMap) {
// Remove all sessions
keys := make([]string, 0, len(cm.sessionMap))
for key := range cm.sessionMap {
keys = append(keys, key)
}
return keys
}
// Collect all timestamps with keys
type sessionAge struct {
lastUsed time.Time
key string
}
sessions := make([]sessionAge, 0, len(cm.sessionMap))
for key, entry := range cm.sessionMap {
sessions = append(sessions, sessionAge{key: key, lastUsed: entry.LastUsed})
}
// Partial sort: get the k smallest elements using selection
// This is O(n*k) which is better than O(n log n) when k << n
for i := 0; i < k; i++ {
minIdx := i
for j := i + 1; j < len(sessions); j++ {
if sessions[j].lastUsed.Before(sessions[minIdx].lastUsed) {
minIdx = j
}
}
if minIdx != i {
sessions[i], sessions[minIdx] = sessions[minIdx], sessions[i]
}
}
// Return the k oldest
result := make([]string, k)
for i := 0; i < k; i++ {
result[i] = sessions[i].key
}
return result
}
// CanCreateSession checks if a new session can be created within limits
func (cm *ChunkManager) CanCreateSession() (bool, error) {
cm.mutex.RLock()
currentCount := len(cm.sessionMap)
cm.mutex.RUnlock()
// Hard limit check - never exceed maxSessions
if currentCount >= cm.maxSessions {
cm.logger.Error("Cannot create session: at maximum limit (%d)", cm.maxSessions)
return false, fmt.Errorf("session storage at maximum capacity (%d sessions)", cm.maxSessions)
}
// Emergency cleanup at 90% capacity
emergencyThreshold := int(float64(cm.maxSessions) * 0.9)
if currentCount >= emergencyThreshold {
cm.logger.Info("Session storage at %d%% capacity, triggering emergency cleanup",
(currentCount*100)/cm.maxSessions)
cm.EmergencyCleanup()
// Recheck after cleanup
cm.mutex.RLock()
newCount := len(cm.sessionMap)
cm.mutex.RUnlock()
if newCount >= cm.maxSessions {
return false, fmt.Errorf("session storage full even after emergency cleanup (%d sessions)", newCount)
}
}
return true, nil
}
// EmergencyCleanup performs aggressive session cleanup when approaching limits
func (cm *ChunkManager) EmergencyCleanup() {
cm.mutex.Lock()
defer cm.mutex.Unlock()
now := time.Now()
removed := 0
// Remove any expired sessions first
expiredKeys := make([]string, 0)
for key, entry := range cm.sessionMap {
if now.After(entry.ExpiresAt) || now.Sub(entry.LastUsed) > cm.sessionTTL {
expiredKeys = append(expiredKeys, key)
}
}
for _, key := range expiredKeys {
if entry, exists := cm.sessionMap[key]; exists {
atomic.AddInt64(&cm.bytesAllocated, -entry.SizeEstimate)
}
delete(cm.sessionMap, key)
removed++
}
// If still over 80% capacity, remove oldest sessions more aggressively
targetCapacity := int(float64(cm.maxSessions) * 0.8)
if len(cm.sessionMap) > targetCapacity {
excessCount := len(cm.sessionMap) - targetCapacity
// Use efficient partial sort to find oldest sessions
keysToRemove := cm.findOldestSessions(excessCount)
for _, key := range keysToRemove {
if entry, exists := cm.sessionMap[key]; exists {
atomic.AddInt64(&cm.bytesAllocated, -entry.SizeEstimate)
}
delete(cm.sessionMap, key)
removed++
}
}
cm.lastCleanup = now
cm.logger.Infof("Emergency cleanup completed: removed %d sessions, %d remaining",
removed, len(cm.sessionMap))
// Log memory stats after emergency cleanup (read only, no forced GC)
cm.logger.Infof("Sessions after emergency cleanup: %d, Tracked bytes: %d",
len(cm.sessionMap), atomic.LoadInt64(&cm.bytesAllocated))
}
// GetSessionCount returns the current number of active sessions (for monitoring)
func (cm *ChunkManager) GetSessionCount() int {
cm.mutex.RLock()
defer cm.mutex.RUnlock()
return len(cm.sessionMap)
}
// GetMemoryStats returns memory usage statistics for monitoring
func (cm *ChunkManager) GetMemoryStats() map[string]interface{} {
cm.mutex.RLock()
sessionCount := len(cm.sessionMap)
cm.mutex.RUnlock()
stats := make(map[string]interface{})
stats["active_sessions"] = sessionCount
stats["max_sessions"] = cm.maxSessions
stats["bytes_allocated"] = atomic.LoadInt64(&cm.bytesAllocated)
stats["peak_sessions"] = atomic.LoadInt64(&cm.peakSessions)
stats["cleanup_count"] = atomic.LoadInt64(&cm.cleanupCount)
stats["session_ttl_hours"] = cm.sessionTTL.Hours()
// Update peak sessions
if int64(sessionCount) > atomic.LoadInt64(&cm.peakSessions) {
atomic.StoreInt64(&cm.peakSessions, int64(sessionCount))
}
return stats
} }
+1 -1
View File
@@ -14,4 +14,4 @@ const devPluginVersion = "0.0.0-dev"
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph, // 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.64" const traefikoidcPluginVersion = "1.0.62"