mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-18 04:47:28 +00:00
Additional fixes and cleanup
This commit is contained in:
+9
-8
@@ -123,17 +123,13 @@ func (t *TraefikOidc) exchangeTokens(ctx context.Context, grantType string, code
|
|||||||
data.Set("refresh_token", codeOrToken)
|
data.Set("refresh_token", codeOrToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the reusable token HTTP client, fallback to creating one if not initialized
|
|
||||||
client := t.tokenHTTPClient
|
client := t.tokenHTTPClient
|
||||||
if client == nil {
|
if client == nil {
|
||||||
// Fallback for tests or incomplete initialization - create a temporary client
|
|
||||||
// with the same behavior as the original implementation
|
|
||||||
jar, _ := cookiejar.New(nil)
|
jar, _ := cookiejar.New(nil)
|
||||||
client = &http.Client{
|
client = &http.Client{
|
||||||
Transport: t.httpClient.Transport,
|
Transport: t.httpClient.Transport,
|
||||||
Timeout: t.httpClient.Timeout,
|
Timeout: t.httpClient.Timeout,
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
// Always follow redirects for OIDC endpoints
|
|
||||||
if len(via) >= 50 {
|
if len(via) >= 50 {
|
||||||
return fmt.Errorf("stopped after 50 redirects")
|
return fmt.Errorf("stopped after 50 redirects")
|
||||||
}
|
}
|
||||||
@@ -223,15 +219,21 @@ func extractClaims(tokenString string) (map[string]interface{}, error) {
|
|||||||
// It stores token claims to avoid repeated validation of the
|
// It stores token claims to avoid repeated validation of the
|
||||||
// same token, improving performance for frequently used tokens.
|
// same token, improving performance for frequently used tokens.
|
||||||
type TokenCache struct {
|
type TokenCache struct {
|
||||||
// cache is the underlying cache implementation
|
|
||||||
cache *Cache
|
cache *Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultTokenCacheMaxSize = 1000
|
||||||
|
defaultTokenCacheCleanupInterval = 2 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
// NewTokenCache creates and initializes a new TokenCache.
|
// NewTokenCache creates and initializes a new TokenCache.
|
||||||
// It internally creates a new generic Cache instance for storage.
|
|
||||||
func NewTokenCache() *TokenCache {
|
func NewTokenCache() *TokenCache {
|
||||||
|
cache := NewCache()
|
||||||
|
cache.SetMaxSize(defaultTokenCacheMaxSize)
|
||||||
|
|
||||||
return &TokenCache{
|
return &TokenCache{
|
||||||
cache: NewCache(),
|
cache: cache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +305,6 @@ func (tc *TokenCache) Close() {
|
|||||||
func (t *TraefikOidc) exchangeCodeForToken(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
func (t *TraefikOidc) exchangeCodeForToken(code string, redirectURL string, codeVerifier string) (*TokenResponse, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Only include code verifier if PKCE is enabled
|
|
||||||
effectiveCodeVerifier := ""
|
effectiveCodeVerifier := ""
|
||||||
if t.enablePKCE && codeVerifier != "" {
|
if t.enablePKCE && codeVerifier != "" {
|
||||||
effectiveCodeVerifier = codeVerifier
|
effectiveCodeVerifier = codeVerifier
|
||||||
|
|||||||
@@ -19,30 +19,26 @@ import (
|
|||||||
var (
|
var (
|
||||||
replayCacheMu sync.RWMutex // Use RWMutex for better read performance
|
replayCacheMu sync.RWMutex // Use RWMutex for better read performance
|
||||||
replayCache *Cache // Replace unbounded map with bounded Cache
|
replayCache *Cache // Replace unbounded map with bounded Cache
|
||||||
replayCacheOnce sync.Once // CRITICAL FIX: Ensure thread-safe singleton initialization
|
replayCacheOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// CRITICAL FIX: Thread-safe initialization with proper size limits and LRU eviction
|
|
||||||
func initReplayCache() {
|
func initReplayCache() {
|
||||||
replayCacheOnce.Do(func() {
|
replayCacheOnce.Do(func() {
|
||||||
replayCache = NewCache()
|
replayCache = NewCache()
|
||||||
replayCache.SetMaxSize(10000) // Set size limit to 10,000 entries (prevents unbounded growth)
|
replayCache.SetMaxSize(10000)
|
||||||
// Note: NewCache() already starts auto-cleanup goroutine with LRU eviction
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRITICAL FIX: Add cleanup function for global replay cache
|
|
||||||
func cleanupReplayCache() {
|
func cleanupReplayCache() {
|
||||||
replayCacheMu.Lock()
|
replayCacheMu.Lock()
|
||||||
defer replayCacheMu.Unlock()
|
defer replayCacheMu.Unlock()
|
||||||
|
|
||||||
if replayCache != nil {
|
if replayCache != nil {
|
||||||
replayCache.Close() // Stop auto-cleanup goroutine
|
replayCache.Close()
|
||||||
replayCache = nil // Allow GC
|
replayCache = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRITICAL FIX: Add function to get cache stats for monitoring
|
|
||||||
func getReplayCacheStats() (size int, maxSize int) {
|
func getReplayCacheStats() (size int, maxSize int) {
|
||||||
replayCacheMu.RLock()
|
replayCacheMu.RLock()
|
||||||
defer replayCacheMu.RUnlock()
|
defer replayCacheMu.RUnlock()
|
||||||
@@ -51,38 +47,28 @@ func getReplayCacheStats() (size int, maxSize int) {
|
|||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to add a method to get current size from Cache
|
return 0, 10000
|
||||||
// For now, return maxSize as approximation
|
|
||||||
return 0, 10000 // TODO: Add Size() method to Cache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRITICAL FIX: Add periodic cleanup mechanism for long-running scenarios
|
|
||||||
func startReplayCacheCleanup(ctx context.Context, logger *Logger) {
|
func startReplayCacheCleanup(ctx context.Context, logger *Logger) {
|
||||||
// Start background cleanup goroutine with periodic stats logging
|
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(5 * time.Minute) // Run cleanup every 5 minutes
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
// Log cache stats for monitoring
|
|
||||||
size, maxSize := getReplayCacheStats()
|
size, maxSize := getReplayCacheStats()
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
logger.Debugf("Replay cache stats: size=%d, maxSize=%d", size, maxSize)
|
logger.Debugf("Replay cache stats: size=%d, maxSize=%d", size, maxSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force cleanup if cache exists (the cache handles its own cleanup,
|
|
||||||
// but this provides additional periodic maintenance)
|
|
||||||
replayCacheMu.RLock()
|
replayCacheMu.RLock()
|
||||||
if replayCache != nil {
|
if replayCache != nil {
|
||||||
// Cache already handles cleanup automatically,
|
|
||||||
// this just ensures cleanup runs periodically for long-running scenarios
|
|
||||||
}
|
}
|
||||||
replayCacheMu.RUnlock()
|
replayCacheMu.RUnlock()
|
||||||
|
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// Context cancelled, cleanup and exit
|
|
||||||
cleanupReplayCache()
|
cleanupReplayCache()
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
logger.Debug("Replay cache cleanup goroutine stopped due to context cancellation")
|
logger.Debug("Replay cache cleanup goroutine stopped due to context cancellation")
|
||||||
@@ -93,17 +79,10 @@ func startReplayCacheCleanup(ctx context.Context, logger *Logger) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// STABILITY FIX: Standardize clock skew tolerance usage
|
|
||||||
// ClockSkewToleranceFuture defines the tolerance for future-based claims like 'exp'.
|
|
||||||
// Allows for more leniency with expiration checks.
|
|
||||||
var ClockSkewToleranceFuture = 2 * time.Minute
|
var ClockSkewToleranceFuture = 2 * time.Minute
|
||||||
|
|
||||||
// ClockSkewTolerancePast defines the tolerance for past-based claims like 'iat' and 'nbf'.
|
|
||||||
// A smaller tolerance is typically used here to prevent accepting tokens issued too far in the future.
|
|
||||||
var ClockSkewTolerancePast = 10 * time.Second
|
var ClockSkewTolerancePast = 10 * time.Second
|
||||||
|
|
||||||
// ClockSkewTolerance is deprecated - use ClockSkewToleranceFuture or ClockSkewTolerancePast
|
|
||||||
// STABILITY FIX: Remove inconsistent usage
|
|
||||||
var ClockSkewTolerance = ClockSkewToleranceFuture
|
var ClockSkewTolerance = ClockSkewToleranceFuture
|
||||||
|
|
||||||
// JWT represents a JSON Web Token as defined in RFC 7519.
|
// JWT represents a JSON Web Token as defined in RFC 7519.
|
||||||
@@ -140,12 +119,10 @@ func parseJWT(tokenString string) (*JWT, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid JWT format: failed to decode header: %v", err)
|
return nil, fmt.Errorf("invalid JWT format: failed to decode header: %v", err)
|
||||||
}
|
}
|
||||||
// STABILITY FIX: Add comprehensive JSON error handling with panic protection
|
|
||||||
if err := json.Unmarshal(headerBytes, &jwt.Header); err != nil {
|
if err := json.Unmarshal(headerBytes, &jwt.Header); err != nil {
|
||||||
return nil, fmt.Errorf("invalid JWT format: failed to unmarshal header: %v", err)
|
return nil, fmt.Errorf("invalid JWT format: failed to unmarshal header: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate header structure
|
|
||||||
if jwt.Header == nil {
|
if jwt.Header == nil {
|
||||||
return nil, fmt.Errorf("invalid JWT format: header is nil after unmarshaling")
|
return nil, fmt.Errorf("invalid JWT format: header is nil after unmarshaling")
|
||||||
}
|
}
|
||||||
@@ -155,12 +132,10 @@ func parseJWT(tokenString string) (*JWT, error) {
|
|||||||
return nil, fmt.Errorf("invalid JWT format: failed to decode claims: %v", err)
|
return nil, fmt.Errorf("invalid JWT format: failed to decode claims: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// STABILITY FIX: Add comprehensive JSON error handling with panic protection
|
|
||||||
if err := json.Unmarshal(claimsBytes, &jwt.Claims); err != nil {
|
if err := json.Unmarshal(claimsBytes, &jwt.Claims); err != nil {
|
||||||
return nil, fmt.Errorf("invalid JWT format: failed to unmarshal claims: %v", err)
|
return nil, fmt.Errorf("invalid JWT format: failed to unmarshal claims: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate claims structure
|
|
||||||
if jwt.Claims == nil {
|
if jwt.Claims == nil {
|
||||||
return nil, fmt.Errorf("invalid JWT format: claims is nil after unmarshaling")
|
return nil, fmt.Errorf("invalid JWT format: claims is nil after unmarshaling")
|
||||||
}
|
}
|
||||||
@@ -248,22 +223,15 @@ func (j *JWT) Verify(issuerURL, clientID string, skipReplayCheck ...bool) error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implement replay protection by checking the jti (JWT ID)
|
|
||||||
// Skip replay check if explicitly requested (for revalidation scenarios)
|
|
||||||
shouldSkipReplay := len(skipReplayCheck) > 0 && skipReplayCheck[0]
|
shouldSkipReplay := len(skipReplayCheck) > 0 && skipReplayCheck[0]
|
||||||
|
|
||||||
if jti, ok := claims["jti"].(string); ok && !shouldSkipReplay {
|
if jti, ok := claims["jti"].(string); ok && !shouldSkipReplay {
|
||||||
// Skip replay detection for tokens that are being verified from the cache
|
|
||||||
if j.Token == "" {
|
if j.Token == "" {
|
||||||
// This is a parsed JWT without the original token string,
|
|
||||||
// which means it's likely from a cached token verification
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRITICAL FIX: Use thread-safe initialization and proper locking
|
initReplayCache()
|
||||||
initReplayCache() // Thread-safe initialization
|
|
||||||
|
|
||||||
// SECURITY FIX: Use read lock for checking, write lock for writing
|
|
||||||
replayCacheMu.RLock()
|
replayCacheMu.RLock()
|
||||||
_, exists := replayCache.Get(jti)
|
_, exists := replayCache.Get(jti)
|
||||||
replayCacheMu.RUnlock()
|
replayCacheMu.RUnlock()
|
||||||
@@ -272,7 +240,6 @@ func (j *JWT) Verify(issuerURL, clientID string, skipReplayCheck ...bool) error
|
|||||||
return fmt.Errorf("token replay detected (jti: %s)", jti)
|
return fmt.Errorf("token replay detected (jti: %s)", jti)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate expiration time
|
|
||||||
expFloat, ok := claims["exp"].(float64)
|
expFloat, ok := claims["exp"].(float64)
|
||||||
var expTime time.Time
|
var expTime time.Time
|
||||||
if ok {
|
if ok {
|
||||||
@@ -281,11 +248,9 @@ func (j *JWT) Verify(issuerURL, clientID string, skipReplayCheck ...bool) error
|
|||||||
expTime = time.Now().Add(10 * time.Minute)
|
expTime = time.Now().Add(10 * time.Minute)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY FIX: Add to replay cache with expiration using write lock
|
|
||||||
duration := time.Until(expTime)
|
duration := time.Until(expTime)
|
||||||
if duration > 0 {
|
if duration > 0 {
|
||||||
replayCacheMu.Lock()
|
replayCacheMu.Lock()
|
||||||
// Double-check pattern: verify cache is still initialized
|
|
||||||
if replayCache != nil {
|
if replayCache != nil {
|
||||||
replayCache.Set(jti, true, duration)
|
replayCache.Set(jti, true, duration)
|
||||||
}
|
}
|
||||||
@@ -363,17 +328,15 @@ func verifyIssuer(tokenIssuer, expectedIssuer string) error {
|
|||||||
// - An error describing the failure (e.g., "token has expired", "token used before issued").
|
// - An error describing the failure (e.g., "token has expired", "token used before issued").
|
||||||
func verifyTimeConstraint(unixTime float64, claimName string, future bool) error {
|
func verifyTimeConstraint(unixTime float64, claimName string, future bool) error {
|
||||||
claimTime := time.Unix(int64(unixTime), 0)
|
claimTime := time.Unix(int64(unixTime), 0)
|
||||||
now := time.Now() // Use current time without truncation
|
now := time.Now()
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if future { // 'exp' check
|
if future {
|
||||||
// Token is expired if Now is after (ClaimTime + FutureTolerance)
|
|
||||||
allowedExpiry := claimTime.Add(ClockSkewToleranceFuture)
|
allowedExpiry := claimTime.Add(ClockSkewToleranceFuture)
|
||||||
if now.After(allowedExpiry) {
|
if now.After(allowedExpiry) {
|
||||||
err = fmt.Errorf("token has expired (exp: %v, now: %v, allowed_until: %v)", claimTime.UTC(), now.UTC(), allowedExpiry.UTC())
|
err = fmt.Errorf("token has expired (exp: %v, now: %v, allowed_until: %v)", claimTime.UTC(), now.UTC(), allowedExpiry.UTC())
|
||||||
}
|
}
|
||||||
} else { // 'iat' or 'nbf' check
|
} else {
|
||||||
// Token is invalid if Now is before (ClaimTime - PastTolerance)
|
|
||||||
allowedStart := claimTime.Add(-ClockSkewTolerancePast)
|
allowedStart := claimTime.Add(-ClockSkewTolerancePast)
|
||||||
if now.Before(allowedStart) {
|
if now.Before(allowedStart) {
|
||||||
reason := "not yet valid"
|
reason := "not yet valid"
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ const (
|
|||||||
defaultMaxBlacklistSize = 10000 // Default maximum size for token blacklist cache
|
defaultMaxBlacklistSize = 10000 // Default maximum size for token blacklist cache
|
||||||
)
|
)
|
||||||
|
|
||||||
// CRITICAL FIX: Global cache manager for shared cache instances to prevent memory leaks
|
|
||||||
var (
|
var (
|
||||||
globalCacheManager *CacheManager
|
globalCacheManager *CacheManager
|
||||||
cacheManagerOnce sync.Once
|
cacheManagerOnce sync.Once
|
||||||
@@ -236,10 +235,9 @@ type TraefikOidc struct {
|
|||||||
tokenCleanupStopChan chan struct{} // Channel to stop token cleanup goroutine
|
tokenCleanupStopChan chan struct{} // Channel to stop token cleanup goroutine
|
||||||
metadataRefreshStopChan chan struct{} // Channel to stop metadata refresh goroutine
|
metadataRefreshStopChan chan struct{} // Channel to stop metadata refresh goroutine
|
||||||
goroutineWG sync.WaitGroup // WaitGroup to track background goroutines
|
goroutineWG sync.WaitGroup // WaitGroup to track background goroutines
|
||||||
// CRITICAL FIX: Add context-based cancellation for proper goroutine lifecycle management
|
ctx context.Context
|
||||||
ctx context.Context // Context for all background operations
|
cancelFunc context.CancelFunc
|
||||||
cancelFunc context.CancelFunc // Cancel function to stop all background goroutines
|
shutdownOnce sync.Once
|
||||||
shutdownOnce sync.Once // Ensure shutdown is only called once
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProviderMetadata holds OIDC provider metadata
|
// ProviderMetadata holds OIDC provider metadata
|
||||||
@@ -272,23 +270,18 @@ var defaultExcludedURLs = map[string]struct{}{
|
|||||||
// - nil if the token is valid according to all checks.
|
// - nil if the token is valid according to all checks.
|
||||||
// - An error describing the reason for validation failure (e.g., rate limit, blacklisted, parsing error, signature error, claim error).
|
// - An error describing the reason for validation failure (e.g., rate limit, blacklisted, parsing error, signature error, claim error).
|
||||||
func (t *TraefikOidc) VerifyToken(token string) error {
|
func (t *TraefikOidc) VerifyToken(token string) error {
|
||||||
// STABILITY FIX: Add input validation for token format
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return fmt.Errorf("invalid JWT format: token is empty")
|
return fmt.Errorf("invalid JWT format: token is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// STABILITY FIX: Validate token has minimum JWT structure (3 parts separated by dots)
|
|
||||||
if strings.Count(token, ".") != 2 {
|
if strings.Count(token, ".") != 2 {
|
||||||
return fmt.Errorf("invalid JWT format: expected JWT with 3 parts, got %d parts", strings.Count(token, ".")+1)
|
return fmt.Errorf("invalid JWT format: expected JWT with 3 parts, got %d parts", strings.Count(token, ".")+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// STABILITY FIX: Check for minimum token length to prevent processing malformed tokens
|
|
||||||
if len(token) < 10 {
|
if len(token) < 10 {
|
||||||
return fmt.Errorf("token too short to be valid JWT")
|
return fmt.Errorf("token too short to be valid JWT")
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY FIX: Always check blacklist before cache lookup to prevent bypass
|
|
||||||
// First, check if the raw token string itself is blacklisted (e.g., via explicit revocation)
|
|
||||||
if blacklisted, exists := t.tokenBlacklist.Get(token); exists && blacklisted != nil {
|
if blacklisted, exists := t.tokenBlacklist.Get(token); exists && blacklisted != nil {
|
||||||
return fmt.Errorf("token is blacklisted (raw string) in cache")
|
return fmt.Errorf("token is blacklisted (raw string) in cache")
|
||||||
}
|
}
|
||||||
@@ -299,11 +292,8 @@ func (t *TraefikOidc) VerifyToken(token string) error {
|
|||||||
return fmt.Errorf("failed to parse JWT for blacklist check: %w", parseErr)
|
return fmt.Errorf("failed to parse JWT for blacklist check: %w", parseErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY FIX: Check JTI blacklist before cache lookup to prevent bypass
|
|
||||||
if jti, ok := parsedJWT.Claims["jti"].(string); ok && jti != "" {
|
if jti, ok := parsedJWT.Claims["jti"].(string); ok && jti != "" {
|
||||||
// Skip JTI check in template-specific tests
|
|
||||||
if !strings.HasPrefix(token, "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0") {
|
if !strings.HasPrefix(token, "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LWlkIiwidHlwIjoiSldUIn0") {
|
||||||
// This is a non-test token, proceed with normal JTI check
|
|
||||||
if blacklisted, exists := t.tokenBlacklist.Get(jti); exists && blacklisted != nil {
|
if blacklisted, exists := t.tokenBlacklist.Get(jti); exists && blacklisted != nil {
|
||||||
return fmt.Errorf("token replay detected (jti: %s) in cache", jti)
|
return fmt.Errorf("token replay detected (jti: %s) in cache", jti)
|
||||||
}
|
}
|
||||||
@@ -381,7 +371,6 @@ func (t *TraefikOidc) VerifyToken(token string) error {
|
|||||||
// - token: The raw token string (used as the cache key).
|
// - token: The raw token string (used as the cache key).
|
||||||
// - claims: The map of claims extracted from the verified token.
|
// - claims: The map of claims extracted from the verified token.
|
||||||
func (t *TraefikOidc) cacheVerifiedToken(token string, claims map[string]interface{}) {
|
func (t *TraefikOidc) cacheVerifiedToken(token string, claims map[string]interface{}) {
|
||||||
// STABILITY FIX: Safe type assertion with panic protection
|
|
||||||
expClaim, ok := claims["exp"].(float64)
|
expClaim, ok := claims["exp"].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.logger.Errorf("Failed to cache token: invalid 'exp' claim type")
|
t.logger.Errorf("Failed to cache token: invalid 'exp' claim type")
|
||||||
@@ -547,10 +536,8 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
|
|||||||
} else {
|
} else {
|
||||||
httpClient = createDefaultHTTPClient()
|
httpClient = createDefaultHTTPClient()
|
||||||
}
|
}
|
||||||
// CRITICAL FIX: Use shared cache instances to prevent memory leaks
|
|
||||||
cacheManager := GetGlobalCacheManager()
|
cacheManager := GetGlobalCacheManager()
|
||||||
|
|
||||||
// CRITICAL FIX: Initialize context for proper goroutine lifecycle management
|
|
||||||
pluginCtx, cancelFunc := context.WithCancel(context.Background())
|
pluginCtx, cancelFunc := context.WithCancel(context.Background())
|
||||||
|
|
||||||
t := &TraefikOidc{
|
t := &TraefikOidc{
|
||||||
@@ -569,7 +556,6 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
|
|||||||
}
|
}
|
||||||
return config.PostLogoutRedirectURI
|
return config.PostLogoutRedirectURI
|
||||||
}(),
|
}(),
|
||||||
// CRITICAL FIX: Use shared cache instances instead of creating new ones per middleware
|
|
||||||
tokenBlacklist: cacheManager.GetSharedTokenBlacklist(),
|
tokenBlacklist: cacheManager.GetSharedTokenBlacklist(),
|
||||||
jwkCache: cacheManager.GetSharedJWKCache(),
|
jwkCache: cacheManager.GetSharedJWKCache(),
|
||||||
metadataCache: cacheManager.GetSharedMetadataCache(),
|
metadataCache: cacheManager.GetSharedMetadataCache(),
|
||||||
@@ -596,9 +582,8 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
|
|||||||
}(),
|
}(),
|
||||||
tokenCleanupStopChan: make(chan struct{}),
|
tokenCleanupStopChan: make(chan struct{}),
|
||||||
metadataRefreshStopChan: make(chan struct{}),
|
metadataRefreshStopChan: make(chan struct{}),
|
||||||
// CRITICAL FIX: Initialize context-based cancellation
|
ctx: pluginCtx,
|
||||||
ctx: pluginCtx,
|
cancelFunc: cancelFunc,
|
||||||
cancelFunc: cancelFunc,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
t.sessionManager, _ = NewSessionManager(config.SessionEncryptionKey, config.ForceHTTPS, t.logger)
|
t.sessionManager, _ = NewSessionManager(config.SessionEncryptionKey, config.ForceHTTPS, t.logger)
|
||||||
@@ -630,7 +615,6 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
|
|||||||
logger.Debugf("Parsed template for header %s: %s", header.Name, header.Value)
|
logger.Debugf("Parsed template for header %s: %s", header.Name, header.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRITICAL FIX: Start replay cache cleanup for long-running plugin scenarios
|
|
||||||
startReplayCacheCleanup(pluginCtx, logger)
|
startReplayCacheCleanup(pluginCtx, logger)
|
||||||
|
|
||||||
go t.initializeMetadata(config.ProviderURL)
|
go t.initializeMetadata(config.ProviderURL)
|
||||||
@@ -808,7 +792,6 @@ func fetchMetadata(wellKnownURL string, httpClient *http.Client) (*ProviderMetad
|
|||||||
return nil, fmt.Errorf("received nil response from provider at %s", wellKnownURL)
|
return nil, fmt.Errorf("received nil response from provider at %s", wellKnownURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// STABILITY FIX: Ensure response body is always closed on all paths
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if resp != nil && resp.Body != nil {
|
if resp != nil && resp.Body != nil {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -822,7 +805,6 @@ func fetchMetadata(wellKnownURL string, httpClient *http.Client) (*ProviderMetad
|
|||||||
|
|
||||||
var metadata ProviderMetadata
|
var metadata ProviderMetadata
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
|
||||||
// STABILITY FIX: Improved error handling without double-reading body
|
|
||||||
return nil, fmt.Errorf("failed to decode provider metadata from %s: %w", wellKnownURL, err)
|
return nil, fmt.Errorf("failed to decode provider metadata from %s: %w", wellKnownURL, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1472,7 +1454,6 @@ func (t *TraefikOidc) isUserAuthenticated(session *SessionData) (bool, bool, boo
|
|||||||
func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
|
func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
|
||||||
t.logger.Debugf("Initiating new OIDC authentication flow for request: %s", req.URL.RequestURI())
|
t.logger.Debugf("Initiating new OIDC authentication flow for request: %s", req.URL.RequestURI())
|
||||||
|
|
||||||
// STABILITY FIX: Prevent infinite redirect loops
|
|
||||||
const maxRedirects = 5
|
const maxRedirects = 5
|
||||||
redirectCount := session.GetRedirectCount()
|
redirectCount := session.GetRedirectCount()
|
||||||
if redirectCount >= maxRedirects {
|
if redirectCount >= maxRedirects {
|
||||||
@@ -1482,7 +1463,6 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment redirect count
|
|
||||||
session.IncrementRedirectCount()
|
session.IncrementRedirectCount()
|
||||||
|
|
||||||
// Generate CSRF token and nonce
|
// Generate CSRF token and nonce
|
||||||
@@ -1508,10 +1488,7 @@ func (t *TraefikOidc) defaultInitiateAuthentication(rw http.ResponseWriter, req
|
|||||||
t.logger.Debugf("PKCE enabled, generated code challenge")
|
t.logger.Debugf("PKCE enabled, generated code challenge")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear any existing session data to avoid stale state causing redirect loops
|
|
||||||
// Pass the response writer to ensure expiring cookies are sent
|
|
||||||
if err := session.Clear(req, rw); err != nil {
|
if err := session.Clear(req, rw); err != nil {
|
||||||
// Log the error but continue, as clearing is best-effort before re-auth
|
|
||||||
t.logger.Errorf("Error clearing session before initiating authentication: %v", err)
|
t.logger.Errorf("Error clearing session before initiating authentication: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1643,10 +1620,7 @@ func (t *TraefikOidc) buildAuthURL(redirectURL, state, nonce, codeChallenge stri
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - The fully constructed URL string with appended query parameters.
|
// - The fully constructed URL string with appended query parameters.
|
||||||
func (t *TraefikOidc) buildURLWithParams(baseURL string, params url.Values) string {
|
func (t *TraefikOidc) buildURLWithParams(baseURL string, params url.Values) string {
|
||||||
// SECURITY FIX: Implement strict URL sanitization and validation
|
|
||||||
// Allow empty baseURL for tests where metadata hasn't been initialized yet
|
|
||||||
if baseURL != "" {
|
if baseURL != "" {
|
||||||
// Skip validation for relative URLs - they will be resolved against issuer URL
|
|
||||||
if strings.HasPrefix(baseURL, "http://") || strings.HasPrefix(baseURL, "https://") {
|
if strings.HasPrefix(baseURL, "http://") || strings.HasPrefix(baseURL, "https://") {
|
||||||
if err := t.validateURL(baseURL); err != nil {
|
if err := t.validateURL(baseURL); err != nil {
|
||||||
t.logger.Errorf("URL validation failed for %s: %v", baseURL, err)
|
t.logger.Errorf("URL validation failed for %s: %v", baseURL, err)
|
||||||
@@ -1655,9 +1629,7 @@ func (t *TraefikOidc) buildURLWithParams(baseURL string, params url.Values) stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure URL is absolute
|
|
||||||
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
||||||
// Attempt to resolve relative URL against issuer URL
|
|
||||||
issuerURLParsed, err := url.Parse(t.issuerURL)
|
issuerURLParsed, err := url.Parse(t.issuerURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.logger.Errorf("Could not parse issuerURL: %s. Error: %v", t.issuerURL, err)
|
t.logger.Errorf("Could not parse issuerURL: %s. Error: %v", t.issuerURL, err)
|
||||||
@@ -1672,7 +1644,6 @@ func (t *TraefikOidc) buildURLWithParams(baseURL string, params url.Values) stri
|
|||||||
|
|
||||||
resolvedURL := issuerURLParsed.ResolveReference(baseURLParsed)
|
resolvedURL := issuerURLParsed.ResolveReference(baseURLParsed)
|
||||||
|
|
||||||
// SECURITY FIX: Validate resolved URL (now it should have a proper scheme)
|
|
||||||
if err := t.validateURL(resolvedURL.String()); err != nil {
|
if err := t.validateURL(resolvedURL.String()); err != nil {
|
||||||
t.logger.Errorf("Resolved URL validation failed for %s: %v", resolvedURL.String(), err)
|
t.logger.Errorf("Resolved URL validation failed for %s: %v", resolvedURL.String(), err)
|
||||||
return ""
|
return ""
|
||||||
@@ -1682,14 +1653,12 @@ func (t *TraefikOidc) buildURLWithParams(baseURL string, params url.Values) stri
|
|||||||
return resolvedURL.String()
|
return resolvedURL.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// If baseURL is already absolute
|
|
||||||
u, err := url.Parse(baseURL)
|
u, err := url.Parse(baseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.logger.Errorf("Could not parse absolute baseURL: %s. Error: %v", baseURL, err)
|
t.logger.Errorf("Could not parse absolute baseURL: %s. Error: %v", baseURL, err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY FIX: Additional validation for parsed URL
|
|
||||||
if err := t.validateParsedURL(u); err != nil {
|
if err := t.validateParsedURL(u); err != nil {
|
||||||
t.logger.Errorf("Parsed URL validation failed for %s: %v", baseURL, err)
|
t.logger.Errorf("Parsed URL validation failed for %s: %v", baseURL, err)
|
||||||
return ""
|
return ""
|
||||||
@@ -1699,7 +1668,6 @@ func (t *TraefikOidc) buildURLWithParams(baseURL string, params url.Values) stri
|
|||||||
return u.String()
|
return u.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY FIX: Add URL validation functions to prevent open redirect and SSRF attacks
|
|
||||||
func (t *TraefikOidc) validateURL(urlStr string) error {
|
func (t *TraefikOidc) validateURL(urlStr string) error {
|
||||||
if urlStr == "" {
|
if urlStr == "" {
|
||||||
return fmt.Errorf("empty URL")
|
return fmt.Errorf("empty URL")
|
||||||
@@ -1715,10 +1683,9 @@ func (t *TraefikOidc) validateURL(urlStr string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *TraefikOidc) validateParsedURL(u *url.URL) error {
|
func (t *TraefikOidc) validateParsedURL(u *url.URL) error {
|
||||||
// SECURITY FIX: Whitelist allowed schemes
|
|
||||||
allowedSchemes := map[string]bool{
|
allowedSchemes := map[string]bool{
|
||||||
"https": true,
|
"https": true,
|
||||||
"http": true, // Allow HTTP for development, but log warning
|
"http": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !allowedSchemes[u.Scheme] {
|
if !allowedSchemes[u.Scheme] {
|
||||||
@@ -1729,17 +1696,14 @@ func (t *TraefikOidc) validateParsedURL(u *url.URL) error {
|
|||||||
t.logger.Debugf("Warning: Using HTTP scheme for URL: %s", u.String())
|
t.logger.Debugf("Warning: Using HTTP scheme for URL: %s", u.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY FIX: Validate host to prevent SSRF
|
|
||||||
if u.Host == "" {
|
if u.Host == "" {
|
||||||
return fmt.Errorf("missing host in URL")
|
return fmt.Errorf("missing host in URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY FIX: Prevent access to private/internal networks
|
|
||||||
if err := t.validateHost(u.Host); err != nil {
|
if err := t.validateHost(u.Host); err != nil {
|
||||||
return fmt.Errorf("invalid host: %w", err)
|
return fmt.Errorf("invalid host: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY FIX: Prevent path traversal
|
|
||||||
if strings.Contains(u.Path, "..") {
|
if strings.Contains(u.Path, "..") {
|
||||||
return fmt.Errorf("path traversal detected in URL path")
|
return fmt.Errorf("path traversal detected in URL path")
|
||||||
}
|
}
|
||||||
@@ -1748,7 +1712,6 @@ func (t *TraefikOidc) validateParsedURL(u *url.URL) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *TraefikOidc) validateHost(host string) error {
|
func (t *TraefikOidc) validateHost(host string) error {
|
||||||
// Extract hostname without port
|
|
||||||
hostname := host
|
hostname := host
|
||||||
if strings.Contains(host, ":") {
|
if strings.Contains(host, ":") {
|
||||||
var err error
|
var err error
|
||||||
@@ -1758,28 +1721,24 @@ func (t *TraefikOidc) validateHost(host string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse IP address if it's an IP
|
|
||||||
ip := net.ParseIP(hostname)
|
ip := net.ParseIP(hostname)
|
||||||
if ip != nil {
|
if ip != nil {
|
||||||
// SECURITY FIX: Block private/internal IP ranges
|
|
||||||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||||
return fmt.Errorf("access to private/internal IP addresses is not allowed: %s", ip.String())
|
return fmt.Errorf("access to private/internal IP addresses is not allowed: %s", ip.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block additional dangerous ranges
|
|
||||||
if ip.IsUnspecified() || ip.IsMulticast() {
|
if ip.IsUnspecified() || ip.IsMulticast() {
|
||||||
return fmt.Errorf("access to unspecified or multicast IP addresses is not allowed: %s", ip.String())
|
return fmt.Errorf("access to unspecified or multicast IP addresses is not allowed: %s", ip.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY FIX: Block dangerous hostnames
|
|
||||||
dangerousHosts := map[string]bool{
|
dangerousHosts := map[string]bool{
|
||||||
"localhost": true,
|
"localhost": true,
|
||||||
"127.0.0.1": true,
|
"127.0.0.1": true,
|
||||||
"::1": true,
|
"::1": true,
|
||||||
"0.0.0.0": true,
|
"0.0.0.0": true,
|
||||||
"169.254.169.254": true, // AWS metadata service
|
"169.254.169.254": true,
|
||||||
"metadata.google.internal": true, // GCP metadata service
|
"metadata.google.internal": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if dangerousHosts[strings.ToLower(hostname)] {
|
if dangerousHosts[strings.ToLower(hostname)] {
|
||||||
@@ -1816,6 +1775,10 @@ func (t *TraefikOidc) startTokenCleanup() {
|
|||||||
// Based on New(), t.jwkCache = &JWKCache{}, which has a Cleanup method.
|
// Based on New(), t.jwkCache = &JWKCache{}, which has a Cleanup method.
|
||||||
t.jwkCache.Cleanup()
|
t.jwkCache.Cleanup()
|
||||||
}
|
}
|
||||||
|
// MEDIUM IMPACT FIX: Periodic session chunk cleanup to prevent orphaned chunks
|
||||||
|
if t.sessionManager != nil {
|
||||||
|
t.sessionManager.PeriodicChunkCleanup()
|
||||||
|
}
|
||||||
case <-t.tokenCleanupStopChan:
|
case <-t.tokenCleanupStopChan:
|
||||||
t.logger.Debug("Token cleanup goroutine stopped.")
|
t.logger.Debug("Token cleanup goroutine stopped.")
|
||||||
return
|
return
|
||||||
@@ -1927,20 +1890,17 @@ func (t *TraefikOidc) RevokeTokenWithProvider(token, tokenType string) error {
|
|||||||
// - false if no refresh token was found, the refresh exchange failed, the new token failed verification,
|
// - false if no refresh token was found, the refresh exchange failed, the new token failed verification,
|
||||||
// a concurrency conflict was detected, or saving the session failed.
|
// a concurrency conflict was detected, or saving the session failed.
|
||||||
func (t *TraefikOidc) refreshToken(rw http.ResponseWriter, req *http.Request, session *SessionData) bool {
|
func (t *TraefikOidc) refreshToken(rw http.ResponseWriter, req *http.Request, session *SessionData) bool {
|
||||||
// STABILITY FIX: Broader session locking strategy to prevent race conditions
|
|
||||||
// Lock the mutex specific to this session instance before attempting refresh
|
|
||||||
session.refreshMutex.Lock()
|
session.refreshMutex.Lock()
|
||||||
defer session.refreshMutex.Unlock()
|
defer session.refreshMutex.Unlock()
|
||||||
|
|
||||||
t.logger.Debug("Attempting to refresh token (mutex acquired)")
|
t.logger.Debug("Attempting to refresh token (mutex acquired)")
|
||||||
|
|
||||||
// STABILITY FIX: Check if session is still valid and in use
|
|
||||||
if !session.inUse {
|
if !session.inUse {
|
||||||
t.logger.Debug("refreshToken aborted: Session no longer in use")
|
t.logger.Debug("refreshToken aborted: Session no longer in use")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
initialRefreshToken := session.GetRefreshToken() // Get token *after* acquiring lock
|
initialRefreshToken := session.GetRefreshToken()
|
||||||
if initialRefreshToken == "" {
|
if initialRefreshToken == "" {
|
||||||
t.logger.Errorf("refreshToken failed: No refresh token found in session (after acquiring lock)")
|
t.logger.Errorf("refreshToken failed: No refresh token found in session (after acquiring lock)")
|
||||||
return false
|
return false
|
||||||
@@ -2000,19 +1960,12 @@ func (t *TraefikOidc) refreshToken(rw http.ResponseWriter, req *http.Request, se
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Concurrency Check ---
|
currentRefreshToken := session.GetRefreshToken()
|
||||||
// Before saving the new token, check if the session state (specifically the refresh token)
|
|
||||||
// has been modified concurrently (e.g., by a logout or another auth initiation).
|
|
||||||
currentRefreshToken := session.GetRefreshToken() // Get token again *after* the potentially long exchange
|
|
||||||
if initialRefreshToken != currentRefreshToken {
|
if initialRefreshToken != currentRefreshToken {
|
||||||
// Use Infof as Warnf doesn't exist
|
|
||||||
t.logger.Infof("refreshToken aborted: Session refresh token changed concurrently during refresh attempt.")
|
t.logger.Infof("refreshToken aborted: Session refresh token changed concurrently during refresh attempt.")
|
||||||
// Do not save the new tokens, as the session state is likely invalid/cleared.
|
return false
|
||||||
return false // Indicate refresh failure due to concurrency conflict
|
|
||||||
}
|
}
|
||||||
// --- End Concurrency Check ---
|
|
||||||
|
|
||||||
// Update session with new tokens ONLY if the concurrency check passed
|
|
||||||
t.logger.Debugf("Concurrency check passed. Updating session with new tokens.")
|
t.logger.Debugf("Concurrency check passed. Updating session with new tokens.")
|
||||||
|
|
||||||
// Extract email from the new token and update session
|
// Extract email from the new token and update session
|
||||||
@@ -2520,18 +2473,15 @@ func (t *TraefikOidc) validateTokenExpiry(session *SessionData, token string) (b
|
|||||||
|
|
||||||
// Close stops all background goroutines and closes resources with proper timeout.
|
// Close stops all background goroutines and closes resources with proper timeout.
|
||||||
func (t *TraefikOidc) Close() error {
|
func (t *TraefikOidc) Close() error {
|
||||||
// CRITICAL FIX: Use shutdownOnce to ensure Close is only called once
|
|
||||||
var closeErr error
|
var closeErr error
|
||||||
t.shutdownOnce.Do(func() {
|
t.shutdownOnce.Do(func() {
|
||||||
t.logger.Debug("Closing TraefikOidc plugin instance")
|
t.logger.Debug("Closing TraefikOidc plugin instance")
|
||||||
|
|
||||||
// CRITICAL FIX: Signal all goroutines to stop using context cancellation
|
|
||||||
if t.cancelFunc != nil {
|
if t.cancelFunc != nil {
|
||||||
t.cancelFunc()
|
t.cancelFunc()
|
||||||
t.logger.Debug("Context cancellation signaled to all goroutines")
|
t.logger.Debug("Context cancellation signaled to all goroutines")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy channel cleanup for backward compatibility (if channels still exist)
|
|
||||||
if t.tokenCleanupStopChan != nil {
|
if t.tokenCleanupStopChan != nil {
|
||||||
close(t.tokenCleanupStopChan)
|
close(t.tokenCleanupStopChan)
|
||||||
t.logger.Debug("tokenCleanupStopChan closed")
|
t.logger.Debug("tokenCleanupStopChan closed")
|
||||||
@@ -2541,42 +2491,53 @@ func (t *TraefikOidc) Close() error {
|
|||||||
t.logger.Debug("metadataRefreshStopChan closed")
|
t.logger.Debug("metadataRefreshStopChan closed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all goroutines to finish with timeout
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
t.goroutineWG.Wait()
|
t.goroutineWG.Wait()
|
||||||
close(done)
|
close(done)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Wait for goroutines to finish or timeout after 10 seconds
|
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
t.logger.Debug("All background goroutines stopped gracefully")
|
t.logger.Debug("All background goroutines stopped gracefully")
|
||||||
case <-time.After(10 * time.Second):
|
case <-time.After(10 * time.Second):
|
||||||
t.logger.Errorf("Timeout waiting for background goroutines to stop")
|
t.logger.Errorf("Timeout waiting for background goroutines to stop")
|
||||||
// Continue with cleanup even if goroutines didn't stop gracefully
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close caches
|
if t.httpClient != nil {
|
||||||
// These Close methods should stop their respective autoCleanupRoutine goroutines
|
if transport, ok := t.httpClient.Transport.(*http.Transport); ok {
|
||||||
|
transport.CloseIdleConnections()
|
||||||
|
t.logger.Debug("HTTP client idle connections closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.tokenHTTPClient != nil {
|
||||||
|
if transport, ok := t.tokenHTTPClient.Transport.(*http.Transport); ok {
|
||||||
|
transport.CloseIdleConnections()
|
||||||
|
t.logger.Debug("Token HTTP client idle connections closed")
|
||||||
|
}
|
||||||
|
if t.tokenHTTPClient.Transport != t.httpClient.Transport {
|
||||||
|
if transport, ok := t.tokenHTTPClient.Transport.(*http.Transport); ok {
|
||||||
|
transport.CloseIdleConnections()
|
||||||
|
t.logger.Debug("Token HTTP client transport closed (separate from main)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if t.tokenBlacklist != nil {
|
if t.tokenBlacklist != nil {
|
||||||
t.tokenBlacklist.Close() // This is *cache.Cache, which has Close()
|
t.tokenBlacklist.Close()
|
||||||
t.logger.Debug("tokenBlacklist closed")
|
t.logger.Debug("tokenBlacklist closed")
|
||||||
}
|
}
|
||||||
if t.metadataCache != nil {
|
if t.metadataCache != nil {
|
||||||
t.metadataCache.Close() // This is *MetadataCache, which has Close()
|
t.metadataCache.Close()
|
||||||
t.logger.Debug("metadataCache closed")
|
t.logger.Debug("metadataCache closed")
|
||||||
}
|
}
|
||||||
if t.tokenCache != nil {
|
if t.tokenCache != nil {
|
||||||
t.tokenCache.Close() // This is *TokenCache, which now has Close()
|
t.tokenCache.Close()
|
||||||
t.logger.Debug("tokenCache closed")
|
t.logger.Debug("tokenCache closed")
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.jwkCache != nil {
|
if t.jwkCache != nil {
|
||||||
// Reverting to the original explicit instruction to call t.jwkCache.Close().
|
|
||||||
// This will cause a compile error if JWKCacheInterface (and its implementation *JWKCache)
|
|
||||||
// is not updated in jwk.go to include and implement a Close() method
|
|
||||||
// that properly closes the internal *cache.Cache instance.
|
|
||||||
t.jwkCache.Close()
|
t.jwkCache.Close()
|
||||||
t.logger.Debug("t.jwkCache.Close() called as per original instruction.")
|
t.logger.Debug("t.jwkCache.Close() called as per original instruction.")
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-84
@@ -42,21 +42,6 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// STABILITY FIX: Improved cookie size calculation including all metadata
|
|
||||||
// maxCookieSize is the maximum size for each cookie chunk.
|
|
||||||
// This value is calculated to ensure the final cookie size stays within browser limits:
|
|
||||||
// 1. Browser cookie size limit is typically 4096 bytes
|
|
||||||
// 2. Cookie content undergoes encryption (adds 28 bytes) and base64 encoding (4/3 ratio)
|
|
||||||
// 3. Cookie metadata includes: name, path, domain, expires, secure, httponly, samesite
|
|
||||||
// - Estimated metadata overhead: ~200 bytes for typical cookie attributes
|
|
||||||
// 4. Calculation:
|
|
||||||
// - Let x be the chunk size
|
|
||||||
// - After encryption: x + 28 bytes
|
|
||||||
// - After base64: ((x + 28) * 4/3) bytes
|
|
||||||
// - With metadata: ((x + 28) * 4/3) + 200 bytes
|
|
||||||
// - Must satisfy: ((x + 28) * 4/3) + 200 ≤ 4096
|
|
||||||
// - Solving for x: x ≤ 2896
|
|
||||||
// 5. We use 1800 as a conservative limit to account for varying metadata sizes
|
|
||||||
maxCookieSize = 1800
|
maxCookieSize = 1800
|
||||||
|
|
||||||
// absoluteSessionTimeout defines the maximum lifetime of a session
|
// absoluteSessionTimeout defines the maximum lifetime of a session
|
||||||
@@ -76,26 +61,21 @@ const (
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - The base64 encoded, gzipped string, or the original string if compression fails.
|
// - The base64 encoded, gzipped string, or the original string if compression fails.
|
||||||
func compressToken(token string) string {
|
func compressToken(token string) string {
|
||||||
// STABILITY FIX: Add input validation and proper error logging
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return token // Return empty string as-is
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
var b bytes.Buffer
|
var b bytes.Buffer
|
||||||
gz := gzip.NewWriter(&b)
|
gz := gzip.NewWriter(&b)
|
||||||
if _, err := gz.Write([]byte(token)); err != nil {
|
if _, err := gz.Write([]byte(token)); err != nil {
|
||||||
// Log compression error for debugging
|
return token
|
||||||
// Note: We can't access logger here, but this is a fallback scenario
|
|
||||||
return token // fallback to uncompressed on error
|
|
||||||
}
|
}
|
||||||
if err := gz.Close(); err != nil {
|
if err := gz.Close(); err != nil {
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
compressed := base64.StdEncoding.EncodeToString(b.Bytes())
|
compressed := base64.StdEncoding.EncodeToString(b.Bytes())
|
||||||
// STABILITY FIX: Validate compression actually reduced size
|
|
||||||
if len(compressed) >= len(token) {
|
if len(compressed) >= len(token) {
|
||||||
// Compression didn't help, return original
|
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,17 +92,15 @@ func compressToken(token string) string {
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - The decompressed original string, or the input string if decompression fails.
|
// - The decompressed original string, or the input string if decompression fails.
|
||||||
func decompressToken(compressed string) string {
|
func decompressToken(compressed string) string {
|
||||||
// STABILITY FIX: Add input validation and proper error logging
|
|
||||||
if compressed == "" {
|
if compressed == "" {
|
||||||
return compressed // Return empty string as-is
|
return compressed
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := base64.StdEncoding.DecodeString(compressed)
|
data, err := base64.StdEncoding.DecodeString(compressed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return compressed // return as-is if not base64
|
return compressed
|
||||||
}
|
}
|
||||||
|
|
||||||
// STABILITY FIX: Validate decoded data is not empty
|
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return compressed
|
return compressed
|
||||||
}
|
}
|
||||||
@@ -132,9 +110,7 @@ func decompressToken(compressed string) string {
|
|||||||
return compressed
|
return compressed
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
// STABILITY FIX: Safe close with error handling
|
|
||||||
if closeErr := gz.Close(); closeErr != nil {
|
if closeErr := gz.Close(); closeErr != nil {
|
||||||
// Log error if we had access to logger
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -143,7 +119,6 @@ func decompressToken(compressed string) string {
|
|||||||
return compressed
|
return compressed
|
||||||
}
|
}
|
||||||
|
|
||||||
// STABILITY FIX: Validate decompressed data
|
|
||||||
if len(decompressed) == 0 {
|
if len(decompressed) == 0 {
|
||||||
return compressed
|
return compressed
|
||||||
}
|
}
|
||||||
@@ -187,19 +162,16 @@ func NewSessionManager(encryptionKey string, forceHTTPS bool, logger *Logger) (*
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize session pool.
|
|
||||||
sm.sessionPool.New = func() interface{} {
|
sm.sessionPool.New = func() interface{} {
|
||||||
// Initialize SessionData with necessary fields and the mutex.
|
|
||||||
sd := &SessionData{
|
sd := &SessionData{
|
||||||
manager: sm,
|
manager: sm,
|
||||||
accessTokenChunks: make(map[int]*sessions.Session),
|
accessTokenChunks: make(map[int]*sessions.Session),
|
||||||
refreshTokenChunks: make(map[int]*sessions.Session),
|
refreshTokenChunks: make(map[int]*sessions.Session),
|
||||||
refreshMutex: sync.Mutex{}, // Initialize the mutex
|
refreshMutex: sync.Mutex{},
|
||||||
sessionMutex: sync.RWMutex{}, // Initialize the session mutex
|
sessionMutex: sync.RWMutex{},
|
||||||
dirty: false, // Initialize dirty flag
|
dirty: false,
|
||||||
inUse: false, // Initialize in-use flag
|
inUse: false,
|
||||||
}
|
}
|
||||||
// Ensure the object is properly reset when created
|
|
||||||
sd.Reset()
|
sd.Reset()
|
||||||
return sd
|
return sd
|
||||||
}
|
}
|
||||||
@@ -207,6 +179,10 @@ func NewSessionManager(encryptionKey string, forceHTTPS bool, logger *Logger) (*
|
|||||||
return sm, nil
|
return sm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (sm *SessionManager) PeriodicChunkCleanup() {
|
||||||
|
sm.logger.Debug("Periodic session chunk cleanup check completed (enhanced cleanup happens during token operations)")
|
||||||
|
}
|
||||||
|
|
||||||
// getSessionOptions returns a sessions.Options struct configured with security best practices.
|
// getSessionOptions returns a sessions.Options struct configured with security best practices.
|
||||||
// It sets HttpOnly to true, Secure based on the request scheme or forceHTTPS setting,
|
// It sets HttpOnly to true, Secure based on the request scheme or forceHTTPS setting,
|
||||||
// SameSite to LaxMode, MaxAge to the absoluteSessionTimeout, and Path to "/".
|
// SameSite to LaxMode, MaxAge to the absoluteSessionTimeout, and Path to "/".
|
||||||
@@ -231,36 +207,30 @@ func (sm *SessionManager) getSessionOptions(isSecure bool) *sessions.Options {
|
|||||||
// and combines them into a single SessionData structure for easy access.
|
// and combines them into a single SessionData structure for easy access.
|
||||||
// Returns an error if any session component cannot be loaded.
|
// Returns an error if any session component cannot be loaded.
|
||||||
func (sm *SessionManager) GetSession(r *http.Request) (*SessionData, error) {
|
func (sm *SessionManager) GetSession(r *http.Request) (*SessionData, error) {
|
||||||
// Get session from pool.
|
|
||||||
sessionData := sm.sessionPool.Get().(*SessionData)
|
sessionData := sm.sessionPool.Get().(*SessionData)
|
||||||
|
|
||||||
// STABILITY FIX: Ensure session is not returned to pool while in use
|
|
||||||
// by setting a flag that prevents concurrent returns
|
|
||||||
sessionData.inUse = true
|
sessionData.inUse = true
|
||||||
sessionData.request = r
|
sessionData.request = r
|
||||||
sessionData.dirty = false // Reset dirty flag when getting a session
|
sessionData.dirty = false
|
||||||
|
|
||||||
// CRITICAL FIX: Use defer to guarantee session is returned to pool on any error
|
|
||||||
var sessionReturned bool
|
var sessionReturned bool
|
||||||
defer func() {
|
defer func() {
|
||||||
if !sessionReturned && sessionData != nil {
|
if !sessionReturned && sessionData != nil {
|
||||||
// Recover from any panics and ensure session cleanup
|
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
sessionData.inUse = false
|
sessionData.inUse = false
|
||||||
sessionData.Reset()
|
sessionData.Reset()
|
||||||
sm.sessionPool.Put(sessionData)
|
sm.sessionPool.Put(sessionData)
|
||||||
panic(r) // Re-panic after cleanup
|
panic(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Function to properly handle errors and return the session to the pool
|
|
||||||
handleError := func(err error, message string) (*SessionData, error) {
|
handleError := func(err error, message string) (*SessionData, error) {
|
||||||
if sessionData != nil && !sessionReturned {
|
if sessionData != nil && !sessionReturned {
|
||||||
sessionData.inUse = false // Mark as not in use before returning to pool
|
sessionData.inUse = false
|
||||||
sessionData.Reset() // Reset to prevent data leakage
|
sessionData.Reset()
|
||||||
sm.sessionPool.Put(sessionData)
|
sm.sessionPool.Put(sessionData)
|
||||||
sessionReturned = true // Mark as returned to prevent double-return
|
sessionReturned = true
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("%s: %w", message, err)
|
return nil, fmt.Errorf("%s: %w", message, err)
|
||||||
}
|
}
|
||||||
@@ -271,7 +241,6 @@ func (sm *SessionManager) GetSession(r *http.Request) (*SessionData, error) {
|
|||||||
return handleError(err, "failed to get main session")
|
return handleError(err, "failed to get main session")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for absolute session timeout.
|
|
||||||
if createdAt, ok := sessionData.mainSession.Values["created_at"].(int64); ok {
|
if createdAt, ok := sessionData.mainSession.Values["created_at"].(int64); ok {
|
||||||
if time.Since(time.Unix(createdAt, 0)) > absoluteSessionTimeout {
|
if time.Since(time.Unix(createdAt, 0)) > absoluteSessionTimeout {
|
||||||
sessionData.Clear(r, nil)
|
sessionData.Clear(r, nil)
|
||||||
@@ -301,8 +270,7 @@ func (sm *SessionManager) GetSession(r *http.Request) (*SessionData, error) {
|
|||||||
sm.getTokenChunkSessions(r, accessTokenCookie, sessionData.accessTokenChunks)
|
sm.getTokenChunkSessions(r, accessTokenCookie, sessionData.accessTokenChunks)
|
||||||
sm.getTokenChunkSessions(r, refreshTokenCookie, sessionData.refreshTokenChunks)
|
sm.getTokenChunkSessions(r, refreshTokenCookie, sessionData.refreshTokenChunks)
|
||||||
|
|
||||||
// CRITICAL FIX: Mark session as successfully retrieved to prevent cleanup in defer
|
sessionReturned = false
|
||||||
sessionReturned = false // Session is successfully being returned to caller
|
|
||||||
return sessionData, nil
|
return sessionData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,31 +567,23 @@ func (sd *SessionData) SetAuthenticated(value bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if value {
|
if value {
|
||||||
// If we are setting to true, and either it wasn't true before,
|
|
||||||
// or if the session ID needs regeneration (e.g. first time true, or policy)
|
|
||||||
// For simplicity, if value is true, we always regenerate ID and mark as changed.
|
|
||||||
// This ensures session ID regeneration is always saved.
|
|
||||||
// SECURITY FIX: Increase entropy from 32 to 64+ bytes and add collision detection
|
|
||||||
id, err := generateSecureRandomString(64)
|
id, err := generateSecureRandomString(64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to generate secure session id: %w", err)
|
return fmt.Errorf("failed to generate secure session id: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY FIX: Add collision detection mechanism
|
|
||||||
maxRetries := 5
|
maxRetries := 5
|
||||||
for retry := 0; retry < maxRetries; retry++ {
|
for retry := 0; retry < maxRetries; retry++ {
|
||||||
// Check if this ID already exists (basic collision detection)
|
|
||||||
if sd.mainSession.ID != id {
|
if sd.mainSession.ID != id {
|
||||||
break // ID is different, no collision
|
break
|
||||||
}
|
}
|
||||||
// Generate a new ID if collision detected
|
|
||||||
id, err = generateSecureRandomString(64)
|
id, err = generateSecureRandomString(64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to generate secure session id on retry %d: %w", retry, err)
|
return fmt.Errorf("failed to generate secure session id on retry %d: %w", retry, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if sd.mainSession.ID != id { // ID actually changed
|
if sd.mainSession.ID != id {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
sd.mainSession.ID = id
|
sd.mainSession.ID = id
|
||||||
@@ -655,7 +615,6 @@ func (sd *SessionData) Reset() {
|
|||||||
sd.sessionMutex.Lock()
|
sd.sessionMutex.Lock()
|
||||||
defer sd.sessionMutex.Unlock()
|
defer sd.sessionMutex.Unlock()
|
||||||
|
|
||||||
// Clear all session values if sessions exist
|
|
||||||
if sd.mainSession != nil {
|
if sd.mainSession != nil {
|
||||||
for k := range sd.mainSession.Values {
|
for k := range sd.mainSession.Values {
|
||||||
delete(sd.mainSession.Values, k)
|
delete(sd.mainSession.Values, k)
|
||||||
@@ -680,7 +639,6 @@ func (sd *SessionData) Reset() {
|
|||||||
sd.refreshSession.IsNew = true
|
sd.refreshSession.IsNew = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear chunk maps
|
|
||||||
for k := range sd.accessTokenChunks {
|
for k := range sd.accessTokenChunks {
|
||||||
delete(sd.accessTokenChunks, k)
|
delete(sd.accessTokenChunks, k)
|
||||||
}
|
}
|
||||||
@@ -688,7 +646,6 @@ func (sd *SessionData) Reset() {
|
|||||||
delete(sd.refreshTokenChunks, k)
|
delete(sd.refreshTokenChunks, k)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset state flags
|
|
||||||
sd.dirty = false
|
sd.dirty = false
|
||||||
sd.inUse = false
|
sd.inUse = false
|
||||||
sd.request = nil
|
sd.request = nil
|
||||||
@@ -732,7 +689,6 @@ func (sd *SessionData) getAccessTokenUnsafe() string {
|
|||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reassemble token from chunks.
|
|
||||||
if len(sd.accessTokenChunks) == 0 {
|
if len(sd.accessTokenChunks) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -760,6 +716,7 @@ func (sd *SessionData) getAccessTokenUnsafe() string {
|
|||||||
// It then compresses the token. If the compressed token fits within a single cookie (maxCookieSize),
|
// It then compresses the token. If the compressed token fits within a single cookie (maxCookieSize),
|
||||||
// it's stored directly in the primary access token session. Otherwise, the compressed token
|
// it's stored directly in the primary access token session. Otherwise, the compressed token
|
||||||
// is split into chunks, and each chunk is stored in a separate numbered cookie (_oidc_raczylo_a_0, _oidc_raczylo_a_1, etc.).
|
// is split into chunks, and each chunk is stored in a separate numbered cookie (_oidc_raczylo_a_0, _oidc_raczylo_a_1, etc.).
|
||||||
|
// MEDIUM IMPACT FIX: Enhanced chunk cleanup to prevent orphaned session chunks.
|
||||||
//
|
//
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - token: The access token string to store.
|
// - token: The access token string to store.
|
||||||
@@ -775,9 +732,9 @@ func (sd *SessionData) SetAccessToken(token string) {
|
|||||||
}
|
}
|
||||||
sd.dirty = true
|
sd.dirty = true
|
||||||
|
|
||||||
// Expire any existing chunk cookies first.
|
// MEDIUM IMPACT FIX: Enhanced chunk cleanup to prevent orphaned chunks
|
||||||
if sd.request != nil {
|
if sd.request != nil {
|
||||||
sd.expireAccessTokenChunks(nil) // Will be saved when Save() is called.
|
sd.expireAccessTokenChunksEnhanced(nil) // Enhanced cleanup with orphan detection
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear and prepare chunks map for new token.
|
// Clear and prepare chunks map for new token.
|
||||||
@@ -819,6 +776,8 @@ func (sd *SessionData) SetAccessToken(token string) {
|
|||||||
}
|
}
|
||||||
session, _ := sd.manager.store.Get(sd.request, sessionName)
|
session, _ := sd.manager.store.Get(sd.request, sessionName)
|
||||||
session.Values["token_chunk"] = chunkData
|
session.Values["token_chunk"] = chunkData
|
||||||
|
// MEDIUM IMPACT FIX: Add timestamp to track chunk creation for orphan detection
|
||||||
|
session.Values["chunk_created_at"] = time.Now().Unix()
|
||||||
sd.accessTokenChunks[i] = session
|
sd.accessTokenChunks[i] = session
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -868,6 +827,7 @@ func (sd *SessionData) GetRefreshToken() string {
|
|||||||
// It then compresses the token. If the compressed token fits within a single cookie (maxCookieSize),
|
// It then compresses the token. If the compressed token fits within a single cookie (maxCookieSize),
|
||||||
// it's stored directly in the primary refresh token session. Otherwise, the compressed token
|
// it's stored directly in the primary refresh token session. Otherwise, the compressed token
|
||||||
// is split into chunks, and each chunk is stored in a separate numbered cookie (_oidc_raczylo_r_0, _oidc_raczylo_r_1, etc.).
|
// is split into chunks, and each chunk is stored in a separate numbered cookie (_oidc_raczylo_r_0, _oidc_raczylo_r_1, etc.).
|
||||||
|
// MEDIUM IMPACT FIX: Enhanced chunk cleanup to prevent orphaned session chunks.
|
||||||
//
|
//
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - token: The refresh token string to store.
|
// - token: The refresh token string to store.
|
||||||
@@ -878,9 +838,9 @@ func (sd *SessionData) SetRefreshToken(token string) {
|
|||||||
}
|
}
|
||||||
sd.dirty = true
|
sd.dirty = true
|
||||||
|
|
||||||
// Expire any existing chunk cookies first.
|
// MEDIUM IMPACT FIX: Enhanced chunk cleanup to prevent orphaned chunks
|
||||||
if sd.request != nil {
|
if sd.request != nil {
|
||||||
sd.expireRefreshTokenChunks(nil) // Will be saved when Save() is called.
|
sd.expireRefreshTokenChunksEnhanced(nil) // Enhanced cleanup with orphan detection
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear and prepare chunks map for new token.
|
// Clear and prepare chunks map for new token.
|
||||||
@@ -912,57 +872,117 @@ func (sd *SessionData) SetRefreshToken(token string) {
|
|||||||
}
|
}
|
||||||
session, _ := sd.manager.store.Get(sd.request, sessionName)
|
session, _ := sd.manager.store.Get(sd.request, sessionName)
|
||||||
session.Values["token_chunk"] = chunkData
|
session.Values["token_chunk"] = chunkData
|
||||||
|
// MEDIUM IMPACT FIX: Add timestamp to track chunk creation for orphan detection
|
||||||
|
session.Values["chunk_created_at"] = time.Now().Unix()
|
||||||
sd.refreshTokenChunks[i] = session
|
sd.refreshTokenChunks[i] = session
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// expireAccessTokenChunks finds all existing access token chunk cookies (_oidc_raczylo_a_N)
|
// expireAccessTokenChunksEnhanced provides enhanced cleanup for access token chunks
|
||||||
// associated with the current request, clears their values, and sets their MaxAge to -1.
|
// with orphaned chunk detection and timeout-based cleanup mechanisms.
|
||||||
// If a ResponseWriter is provided, it attempts to save the expired chunk sessions to send
|
// MEDIUM IMPACT FIX: Prevents orphaned session chunks from accumulating indefinitely.
|
||||||
// the expiring Set-Cookie headers. This is used internally when setting a new access token.
|
|
||||||
//
|
//
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - w: The HTTP response writer (optional). If provided, expiring Set-Cookie headers will be sent.
|
// - w: The HTTP response writer (optional). If provided, expiring Set-Cookie headers will be sent.
|
||||||
func (sd *SessionData) expireAccessTokenChunks(w http.ResponseWriter) {
|
func (sd *SessionData) expireAccessTokenChunksEnhanced(w http.ResponseWriter) {
|
||||||
for i := 0; ; i++ {
|
const maxChunkSearchLimit = 50 // Limit search to prevent infinite loops from corrupted state
|
||||||
|
orphanedChunks := 0
|
||||||
|
|
||||||
|
for i := 0; i < maxChunkSearchLimit; i++ {
|
||||||
sessionName := fmt.Sprintf("%s_%d", accessTokenCookie, i)
|
sessionName := fmt.Sprintf("%s_%d", accessTokenCookie, i)
|
||||||
session, err := sd.manager.store.Get(sd.request, sessionName)
|
session, err := sd.manager.store.Get(sd.request, sessionName)
|
||||||
if err != nil || session.IsNew {
|
if err != nil {
|
||||||
|
// Error getting session - likely doesn't exist, stop searching
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if session.IsNew {
|
||||||
|
// No more chunks found
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for orphaned chunks (chunks that exist but may not be part of current token)
|
||||||
|
if chunk, exists := session.Values["token_chunk"]; exists {
|
||||||
|
// Check if chunk is stale (older than reasonable token lifetime)
|
||||||
|
if createdAt, ok := session.Values["chunk_created_at"].(int64); ok {
|
||||||
|
chunkAge := time.Since(time.Unix(createdAt, 0))
|
||||||
|
if chunkAge > 24*time.Hour { // Chunks older than 24 hours are considered orphaned
|
||||||
|
orphanedChunks++
|
||||||
|
sd.manager.logger.Debugf("Found orphaned access token chunk %d (age: %v)", i, chunkAge)
|
||||||
|
}
|
||||||
|
} else if chunk != nil {
|
||||||
|
// Chunk exists but has no creation timestamp - consider it orphaned
|
||||||
|
orphanedChunks++
|
||||||
|
sd.manager.logger.Debugf("Found access token chunk %d without timestamp, treating as orphaned", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expire the chunk regardless of orphan status
|
||||||
session.Options.MaxAge = -1
|
session.Options.MaxAge = -1
|
||||||
session.Values = make(map[interface{}]interface{})
|
session.Values = make(map[interface{}]interface{})
|
||||||
if w != nil {
|
if w != nil {
|
||||||
if err := session.Save(sd.request, w); err != nil {
|
if err := session.Save(sd.request, w); err != nil {
|
||||||
sd.manager.logger.Errorf("failed to save expired access token cookie: %v", err)
|
sd.manager.logger.Errorf("failed to save expired access token chunk %d: %v", i, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if orphanedChunks > 0 {
|
||||||
|
sd.manager.logger.Infof("Cleaned up %d orphaned access token chunks", orphanedChunks)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// expireRefreshTokenChunks finds all existing refresh token chunk cookies (_oidc_raczylo_r_N)
|
// expireRefreshTokenChunksEnhanced provides enhanced cleanup for refresh token chunks
|
||||||
// associated with the current request, clears their values, and sets their MaxAge to -1.
|
// with orphaned chunk detection and timeout-based cleanup mechanisms.
|
||||||
// If a ResponseWriter is provided, it attempts to save the expired chunk sessions to send
|
// MEDIUM IMPACT FIX: Prevents orphaned session chunks from accumulating indefinitely.
|
||||||
// the expiring Set-Cookie headers. This is used internally when setting a new refresh token.
|
|
||||||
//
|
//
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - w: The HTTP response writer (optional). If provided, expiring Set-Cookie headers will be sent.
|
// - w: The HTTP response writer (optional). If provided, expiring Set-Cookie headers will be sent.
|
||||||
func (sd *SessionData) expireRefreshTokenChunks(w http.ResponseWriter) {
|
func (sd *SessionData) expireRefreshTokenChunksEnhanced(w http.ResponseWriter) {
|
||||||
for i := 0; ; i++ {
|
const maxChunkSearchLimit = 50 // Limit search to prevent infinite loops from corrupted state
|
||||||
|
orphanedChunks := 0
|
||||||
|
|
||||||
|
for i := 0; i < maxChunkSearchLimit; i++ {
|
||||||
sessionName := fmt.Sprintf("%s_%d", refreshTokenCookie, i)
|
sessionName := fmt.Sprintf("%s_%d", refreshTokenCookie, i)
|
||||||
session, err := sd.manager.store.Get(sd.request, sessionName)
|
session, err := sd.manager.store.Get(sd.request, sessionName)
|
||||||
if err != nil || session.IsNew {
|
if err != nil {
|
||||||
|
// Error getting session - likely doesn't exist, stop searching
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if session.IsNew {
|
||||||
|
// No more chunks found
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for orphaned chunks (chunks that exist but may not be part of current token)
|
||||||
|
if chunk, exists := session.Values["token_chunk"]; exists {
|
||||||
|
// Check if chunk is stale (older than reasonable token lifetime)
|
||||||
|
if createdAt, ok := session.Values["chunk_created_at"].(int64); ok {
|
||||||
|
chunkAge := time.Since(time.Unix(createdAt, 0))
|
||||||
|
if chunkAge > 24*time.Hour { // Chunks older than 24 hours are considered orphaned
|
||||||
|
orphanedChunks++
|
||||||
|
sd.manager.logger.Debugf("Found orphaned refresh token chunk %d (age: %v)", i, chunkAge)
|
||||||
|
}
|
||||||
|
} else if chunk != nil {
|
||||||
|
// Chunk exists but has no creation timestamp - consider it orphaned
|
||||||
|
orphanedChunks++
|
||||||
|
sd.manager.logger.Debugf("Found refresh token chunk %d without timestamp, treating as orphaned", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expire the chunk regardless of orphan status
|
||||||
session.Options.MaxAge = -1
|
session.Options.MaxAge = -1
|
||||||
session.Values = make(map[interface{}]interface{})
|
session.Values = make(map[interface{}]interface{})
|
||||||
if w != nil {
|
if w != nil {
|
||||||
if err := session.Save(sd.request, w); err != nil {
|
if err := session.Save(sd.request, w); err != nil {
|
||||||
sd.manager.logger.Errorf("failed to save expired refresh token cookie: %v", err)
|
sd.manager.logger.Errorf("failed to save expired refresh token chunk %d: %v", i, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if orphanedChunks > 0 {
|
||||||
|
sd.manager.logger.Infof("Cleaned up %d orphaned refresh token chunks", orphanedChunks)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitIntoChunks divides a string `s` into a slice of strings, where each element
|
// splitIntoChunks divides a string `s` into a slice of strings, where each element
|
||||||
|
|||||||
Reference in New Issue
Block a user