mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
Complete rebuild of the plugin
* Fix bug affecting Azure OIDC authentication ( and most likely others ) * Fixes issue #51 * Ensure that appended roles are unique. Update the documentation. * Improvements targetting possible memory usage spikes. * Additional fixes and cleanup * Refactoring code to fix the issues identified by the users. * Modernize run * Fieldalignment * Multiple changes to improve performance and reduce complexity. - Optimise the errors and recovery. - Deduplicate code in metadata cache. - Remove unused performance monitoring code. - Simplify session management and settings handling. * Fix claims issue. * Add ability to overwrite the default scopes in the settings file * Well.. that escalated quickly. Completely forgot that Traefik uses outdated Yaegi and requires compatibility with 1.20 ( pre-generic Go code ). * Bugfix #51: Ensures that user provided scopes overrides work. * fixup! Bugfix #51: Ensures that user provided scopes overrides work. * fixup! fixup! Bugfix #51: Ensures that user provided scopes overrides work. * Abstract the provider logic into a separate package. * Additional micro fixes and cleanups. * Simplify all the things. * fixup! Simplify all the things. * fixup! fixup! Simplify all the things. * fixup! fixup! fixup! Simplify all the things. * fixup! fixup! fixup! fixup! Simplify all the things. * ... * Cleanup tests. * fixup! Cleanup tests. * fixup! fixup! fixup! Cleanup tests. * fixup! fixup! fixup! fixup! Cleanup tests. * fixup! fixup! fixup! fixup! fixup! Cleanup tests. * Issue #53: Fix CSRF token handling in reverse proxy 1. ✅ HTTPS Detection Fixed (session.go:723) - Now uses X-Forwarded-Proto header instead of r.URL.Scheme - Properly detects HTTPS in reverse proxy environments 2. ✅ SameSite Cookie Attribute Fixed - Removed automatic SameSiteStrictMode for HTTPS (would break OAuth) - Keeps SameSiteLaxMode to allow OAuth callbacks from external domains - Only uses Strict for AJAX requests which don't involve OAuth redirects 3. ✅ Cookie Domain Handling Fixed - Now respects X-Forwarded-Host header for cookie domain - Ensures cookies are set for the public domain, not internal proxy domain 4. ✅ EnhanceSessionSecurity Properly Integrated - Function is now actually called during session save - Applies security enhancements without breaking OAuth flow Why Issue #53 Failed Before: 1. Cookies were not marked Secure in HTTPS environments (browser wouldn't send them back) 2. If they had been Secure with SameSite=Strict, Azure callbacks would still fail 3. Cookie domain might have been wrong (internal vs public domain) Why It Works Now: 1. Cookies are properly marked Secure for HTTPS 2. Uses SameSite=Lax to allow OAuth provider callbacks 3. Cookie domain uses public domain from X-Forwarded-Host 4. CSRF token persists through the entire OAuth flow * Next set of enhancements together with memory usage improvements. * Memory leak fixes and optimisations. * CSRF and Cookie Domain fixes * fixup! CSRF and Cookie Domain fixes * Metadata cache leak fix + profiling * fixup! Metadata cache leak fix + profiling * Memory leaks hunting, part 1337. * Further pursue of perfection. * fixup! Further pursue of perfection. * fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * Clear race conditions * fixup! Clear race conditions * Weekend fun with memory leaks * Splitting code into multiple files with reasonable testing coverage. ``` ok github.com/lukaszraczylo/traefikoidc 117.017s coverage: 72.6% of statements ok github.com/lukaszraczylo/traefikoidc/auth 0.505s coverage: 87.1% of statements ok github.com/lukaszraczylo/traefikoidc/circuit_breaker 0.283s coverage: 99.0% of statements github.com/lukaszraczylo/traefikoidc/config coverage: 0.0% of statements ok github.com/lukaszraczylo/traefikoidc/handlers 0.349s coverage: 98.2% of statements ok github.com/lukaszraczylo/traefikoidc/internal/providers (cached) coverage: 94.3% of statements ok github.com/lukaszraczylo/traefikoidc/middleware 0.808s coverage: 78.0% of statements ok github.com/lukaszraczylo/traefikoidc/recovery 0.653s coverage: 100.0% of statements ok github.com/lukaszraczylo/traefikoidc/session/chunking (cached) coverage: 87.8% of statements ok github.com/lukaszraczylo/traefikoidc/session/core (cached) coverage: 85.6% of statements ok github.com/lukaszraczylo/traefikoidc/session/crypto (cached) coverage: 81.8% of statements ok github.com/lukaszraczylo/traefikoidc/session/storage (cached) coverage: 93.5% of statements ok github.com/lukaszraczylo/traefikoidc/session/validators (cached) coverage: 98.8% of statements ```` * fixup! Splitting code into multiple files with reasonable testing coverage. * fixup! fixup! Splitting code into multiple files with reasonable testing coverage. * Weekend fun with further optimisations. * fixup! Weekend fun with further optimisations. * fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! fixup! fixup! Weekend fun with further optimisations. * Pre-release cleanup. * Enhance test coverage. * fixup! Enhance test coverage. * fixup! fixup! Enhance test coverage. * fixup! fixup! fixup! Enhance test coverage.
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
// Package chunking provides session chunking functionality for large tokens
|
||||
package chunking
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
const (
|
||||
maxCookieSize = 1200
|
||||
)
|
||||
|
||||
// TokenConfig defines validation and storage parameters for different token types.
|
||||
// It specifies size limits, format requirements, and security constraints to ensure
|
||||
// tokens can be safely stored in browser cookies while maintaining security.
|
||||
type TokenConfig struct {
|
||||
Type string
|
||||
MinLength int
|
||||
MaxLength int
|
||||
MaxChunks int
|
||||
MaxChunkSize int
|
||||
AllowOpaqueTokens bool
|
||||
RequireJWTFormat bool
|
||||
}
|
||||
|
||||
// Global session tracking to prevent memory leaks across all instances
|
||||
var (
|
||||
globalSessionCount int64 = 0
|
||||
globalMaxSessions int64 = 5000 // CRITICAL FIX: Global limit of 5000 total sessions
|
||||
)
|
||||
|
||||
// Predefined configurations for each token type
|
||||
var (
|
||||
AccessTokenConfig = TokenConfig{
|
||||
Type: "access",
|
||||
MinLength: 5,
|
||||
MaxLength: 100 * 1024,
|
||||
MaxChunks: 25,
|
||||
MaxChunkSize: maxCookieSize,
|
||||
AllowOpaqueTokens: true,
|
||||
RequireJWTFormat: false,
|
||||
}
|
||||
|
||||
RefreshTokenConfig = TokenConfig{
|
||||
Type: "refresh",
|
||||
MinLength: 5,
|
||||
MaxLength: 50 * 1024,
|
||||
MaxChunks: 15,
|
||||
MaxChunkSize: maxCookieSize,
|
||||
AllowOpaqueTokens: true,
|
||||
RequireJWTFormat: false,
|
||||
}
|
||||
|
||||
IDTokenConfig = TokenConfig{
|
||||
Type: "id",
|
||||
MinLength: 5,
|
||||
MaxLength: 75 * 1024,
|
||||
MaxChunks: 20,
|
||||
MaxChunkSize: maxCookieSize,
|
||||
AllowOpaqueTokens: false,
|
||||
RequireJWTFormat: true,
|
||||
}
|
||||
)
|
||||
|
||||
// TokenRetrievalResult represents the outcome of a token retrieval operation.
|
||||
// It contains either the successfully retrieved token or an error describing
|
||||
// what went wrong during retrieval.
|
||||
type TokenRetrievalResult struct {
|
||||
Error error
|
||||
Token string
|
||||
}
|
||||
|
||||
// SessionEntry represents a session with expiration tracking
|
||||
type SessionEntry struct {
|
||||
Session *sessions.Session
|
||||
ExpiresAt time.Time
|
||||
LastUsed time.Time
|
||||
}
|
||||
|
||||
// Logger interface for dependency injection
|
||||
type Logger interface {
|
||||
Debug(msg string)
|
||||
Debugf(format string, args ...interface{})
|
||||
Error(msg string)
|
||||
Errorf(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// ChunkManager handles the complex logic of storing and retrieving large tokens
|
||||
// across multiple HTTP cookies. It provides comprehensive validation, security checks,
|
||||
// and error handling to ensure data integrity and prevent security vulnerabilities
|
||||
// throughout the process.
|
||||
type ChunkManager struct {
|
||||
logger Logger
|
||||
mutex *sync.RWMutex
|
||||
// sessionMap provides bounded session storage to prevent memory leaks
|
||||
sessionMap map[string]*SessionEntry
|
||||
maxSessions int
|
||||
sessionTTL time.Duration
|
||||
lastCleanup time.Time
|
||||
}
|
||||
|
||||
// NewChunkManager creates a new ChunkManager instance with proper initialization.
|
||||
// It sets up logging and synchronization primitives for safe concurrent access.
|
||||
func NewChunkManager(logger Logger) *ChunkManager {
|
||||
if logger == nil {
|
||||
logger = NewNoOpLogger()
|
||||
}
|
||||
|
||||
return &ChunkManager{
|
||||
logger: logger,
|
||||
mutex: &sync.RWMutex{},
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetToken retrieves a token from either a single cookie or multiple chunk cookies.
|
||||
// It handles both compressed and uncompressed tokens and performs comprehensive
|
||||
// validation throughout the retrieval process.
|
||||
func (cm *ChunkManager) GetToken(
|
||||
mainSession *sessions.Session,
|
||||
chunks map[int]*sessions.Session,
|
||||
config TokenConfig,
|
||||
compressor TokenCompressor,
|
||||
) TokenRetrievalResult {
|
||||
|
||||
// Try to get token from main session first
|
||||
if mainSession != nil {
|
||||
if tokenValue, ok := mainSession.Values[config.Type+"_token"].(string); ok && tokenValue != "" {
|
||||
cm.logger.Debugf("Found %s token in main session", config.Type)
|
||||
|
||||
// Check if token is compressed
|
||||
decompressed := compressor.DecompressToken(tokenValue)
|
||||
if decompressed != tokenValue {
|
||||
cm.logger.Debugf("Decompressed %s token", config.Type)
|
||||
return cm.processSingleToken(decompressed, true, config)
|
||||
}
|
||||
|
||||
return cm.processSingleToken(tokenValue, false, config)
|
||||
}
|
||||
}
|
||||
|
||||
// If not in main session, try chunks
|
||||
if len(chunks) == 0 {
|
||||
return TokenRetrievalResult{
|
||||
Error: nil,
|
||||
Token: "",
|
||||
}
|
||||
}
|
||||
|
||||
cm.logger.Debugf("Found %d chunks for %s token, processing", len(chunks), config.Type)
|
||||
return cm.processChunkedToken(chunks, config, compressor)
|
||||
}
|
||||
|
||||
// processSingleToken validates and processes a single token
|
||||
func (cm *ChunkManager) processSingleToken(token string, compressed bool, config TokenConfig) TokenRetrievalResult {
|
||||
if compressed {
|
||||
cm.logger.Debugf("Processing compressed %s token (length: %d)", config.Type, len(token))
|
||||
} else {
|
||||
cm.logger.Debugf("Processing single %s token (length: %d)", config.Type, len(token))
|
||||
}
|
||||
|
||||
return cm.validateToken(token, config)
|
||||
}
|
||||
|
||||
// validateToken performs comprehensive validation on a token
|
||||
func (cm *ChunkManager) validateToken(token string, config TokenConfig) TokenRetrievalResult {
|
||||
if token == "" {
|
||||
return TokenRetrievalResult{Error: nil, Token: ""}
|
||||
}
|
||||
|
||||
validator := NewTokenValidator()
|
||||
|
||||
// Basic validation
|
||||
if err := validator.ValidateTokenSize(token, config); err != nil {
|
||||
cm.logger.Errorf("Token size validation failed for %s: %v", config.Type, err)
|
||||
return TokenRetrievalResult{Error: err, Token: ""}
|
||||
}
|
||||
|
||||
// Format validation
|
||||
if config.RequireJWTFormat {
|
||||
if err := validator.ValidateJWTFormat(token, config.Type); err != nil {
|
||||
cm.logger.Errorf("JWT format validation failed for %s: %v", config.Type, err)
|
||||
return TokenRetrievalResult{Error: err, Token: ""}
|
||||
}
|
||||
} else if !config.AllowOpaqueTokens {
|
||||
if err := validator.ValidateJWTFormat(token, config.Type); err != nil {
|
||||
cm.logger.Errorf("Token format validation failed for %s: %v", config.Type, err)
|
||||
return TokenRetrievalResult{Error: err, Token: ""}
|
||||
}
|
||||
}
|
||||
|
||||
// Content validation
|
||||
if err := validator.ValidateTokenContent(token, config); err != nil {
|
||||
cm.logger.Errorf("Token content validation failed for %s: %v", config.Type, err)
|
||||
return TokenRetrievalResult{Error: err, Token: ""}
|
||||
}
|
||||
|
||||
cm.logger.Debugf("Successfully validated %s token", config.Type)
|
||||
return TokenRetrievalResult{Error: nil, Token: token}
|
||||
}
|
||||
|
||||
// processChunkedToken reconstructs a token from multiple chunks
|
||||
func (cm *ChunkManager) processChunkedToken(chunks map[int]*sessions.Session, config TokenConfig, compressor TokenCompressor) TokenRetrievalResult {
|
||||
if len(chunks) > config.MaxChunks {
|
||||
return TokenRetrievalResult{
|
||||
Error: &ChunkError{
|
||||
Type: config.Type,
|
||||
Reason: "too many chunks",
|
||||
Details: "chunk count exceeds maximum allowed",
|
||||
},
|
||||
Token: "",
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct token from chunks
|
||||
reconstructedToken, err := cm.reconstructTokenFromChunks(chunks, config)
|
||||
if err != nil {
|
||||
cm.logger.Errorf("Failed to reconstruct %s token from chunks: %v", config.Type, err)
|
||||
return TokenRetrievalResult{Error: err, Token: ""}
|
||||
}
|
||||
|
||||
// Try decompression
|
||||
decompressedToken := compressor.DecompressToken(reconstructedToken)
|
||||
if decompressedToken != reconstructedToken {
|
||||
cm.logger.Debugf("Decompressed reconstructed %s token", config.Type)
|
||||
return cm.validateToken(decompressedToken, config)
|
||||
}
|
||||
|
||||
return cm.validateToken(reconstructedToken, config)
|
||||
}
|
||||
|
||||
// reconstructTokenFromChunks reconstructs a token from ordered chunks
|
||||
func (cm *ChunkManager) reconstructTokenFromChunks(chunks map[int]*sessions.Session, config TokenConfig) (string, error) {
|
||||
if len(chunks) == 0 {
|
||||
return "", &ChunkError{
|
||||
Type: config.Type,
|
||||
Reason: "no chunks found",
|
||||
Details: "no chunk sessions available for reconstruction",
|
||||
}
|
||||
}
|
||||
|
||||
// Find the maximum chunk index to determine total chunks
|
||||
maxIndex := -1
|
||||
for index := range chunks {
|
||||
if index > maxIndex {
|
||||
maxIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
if maxIndex < 0 {
|
||||
return "", &ChunkError{
|
||||
Type: config.Type,
|
||||
Reason: "invalid chunk indices",
|
||||
Details: "no valid chunk indices found",
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct token by concatenating chunks in order
|
||||
var tokenBuilder strings.Builder
|
||||
for i := 0; i <= maxIndex; i++ {
|
||||
chunk, exists := chunks[i]
|
||||
if !exists || chunk == nil {
|
||||
return "", &ChunkError{
|
||||
Type: config.Type,
|
||||
Reason: "missing chunk",
|
||||
Details: fmt.Sprintf("chunk %d is missing", i),
|
||||
}
|
||||
}
|
||||
|
||||
chunkValue, ok := chunk.Values["value"].(string)
|
||||
if !ok || chunkValue == "" {
|
||||
return "", &ChunkError{
|
||||
Type: config.Type,
|
||||
Reason: "empty chunk",
|
||||
Details: fmt.Sprintf("chunk %d has no value", i),
|
||||
}
|
||||
}
|
||||
|
||||
tokenBuilder.WriteString(chunkValue)
|
||||
}
|
||||
|
||||
reconstructed := tokenBuilder.String()
|
||||
if reconstructed == "" {
|
||||
return "", &ChunkError{
|
||||
Type: config.Type,
|
||||
Reason: "empty reconstructed token",
|
||||
Details: "all chunks were present but resulted in empty token",
|
||||
}
|
||||
}
|
||||
|
||||
cm.logger.Debugf("Successfully reconstructed %s token from %d chunks (length: %d)",
|
||||
config.Type, len(chunks), len(reconstructed))
|
||||
|
||||
return reconstructed, nil
|
||||
}
|
||||
|
||||
// CleanupExpiredSessions removes expired sessions from the session map
|
||||
func (cm *ChunkManager) CleanupExpiredSessions() {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Only cleanup if enough time has passed
|
||||
if now.Sub(cm.lastCleanup) < time.Hour {
|
||||
return
|
||||
}
|
||||
|
||||
cm.lastCleanup = now
|
||||
cleaned := 0
|
||||
|
||||
for key, entry := range cm.sessionMap {
|
||||
if now.After(entry.ExpiresAt) || now.Sub(entry.LastUsed) > cm.sessionTTL {
|
||||
delete(cm.sessionMap, key)
|
||||
cleaned++
|
||||
}
|
||||
}
|
||||
|
||||
if cleaned > 0 {
|
||||
cm.logger.Debugf("Cleaned up %d expired sessions", cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
// StoreSession stores a session in the session map with expiration tracking
|
||||
func (cm *ChunkManager) StoreSession(key string, session *sessions.Session) {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
|
||||
// CRITICAL FIX: Aggressive session limit enforcement
|
||||
currentLocal := len(cm.sessionMap)
|
||||
currentGlobal := atomic.LoadInt64(&globalSessionCount)
|
||||
|
||||
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 {
|
||||
// Find oldest sessions to remove
|
||||
type sessionAge struct {
|
||||
key string
|
||||
lastUsed time.Time
|
||||
}
|
||||
|
||||
sessions := make([]sessionAge, 0, currentLocal)
|
||||
for k, entry := range cm.sessionMap {
|
||||
sessions = append(sessions, sessionAge{key: k, lastUsed: entry.LastUsed})
|
||||
}
|
||||
|
||||
// Sort by last used time (oldest first)
|
||||
for i := 0; i < len(sessions)-1; i++ {
|
||||
for j := i + 1; j < len(sessions); j++ {
|
||||
if sessions[i].lastUsed.After(sessions[j].lastUsed) {
|
||||
sessions[i], sessions[j] = sessions[j], sessions[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove excess sessions
|
||||
excessCount := currentLocal - targetCapacity
|
||||
if excessCount < 0 {
|
||||
excessCount = 0
|
||||
}
|
||||
|
||||
removedCount := int64(0)
|
||||
for i := 0; i < excessCount && i < len(sessions); i++ {
|
||||
delete(cm.sessionMap, sessions[i].key)
|
||||
removedCount++
|
||||
}
|
||||
|
||||
if removedCount > 0 {
|
||||
atomic.AddInt64(&globalSessionCount, -removedCount)
|
||||
}
|
||||
}
|
||||
|
||||
cm.sessionMap[key] = &SessionEntry{
|
||||
Session: session,
|
||||
ExpiresAt: time.Now().Add(cm.sessionTTL),
|
||||
LastUsed: time.Now(),
|
||||
}
|
||||
atomic.AddInt64(&globalSessionCount, 1) // CRITICAL FIX: Track addition
|
||||
}
|
||||
|
||||
// GetSession retrieves a session from the session map
|
||||
func (cm *ChunkManager) GetSession(key string) *sessions.Session {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
|
||||
entry, exists := cm.sessionMap[key]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update last used time
|
||||
entry.LastUsed = time.Now()
|
||||
return entry.Session
|
||||
}
|
||||
|
||||
// TokenCompressor interface for token compression operations
|
||||
type TokenCompressor interface {
|
||||
CompressToken(token string) string
|
||||
DecompressToken(compressed string) string
|
||||
}
|
||||
|
||||
// ChunkError represents errors that occur during chunk operations
|
||||
type ChunkError struct {
|
||||
Type string
|
||||
Reason string
|
||||
Details string
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (ce *ChunkError) Error() string {
|
||||
return fmt.Sprintf("%s chunk error: %s - %s", ce.Type, ce.Reason, ce.Details)
|
||||
}
|
||||
|
||||
// NoOpLogger provides a no-op logger implementation
|
||||
type NoOpLogger struct{}
|
||||
|
||||
// NewNoOpLogger creates a new no-op logger
|
||||
func NewNoOpLogger() *NoOpLogger {
|
||||
return &NoOpLogger{}
|
||||
}
|
||||
|
||||
// Debug does nothing
|
||||
func (l *NoOpLogger) Debug(msg string) {}
|
||||
|
||||
// Debugf does nothing
|
||||
func (l *NoOpLogger) Debugf(format string, args ...interface{}) {}
|
||||
|
||||
// Error does nothing
|
||||
func (l *NoOpLogger) Error(msg string) {}
|
||||
|
||||
// Errorf does nothing
|
||||
func (l *NoOpLogger) Errorf(format string, args ...interface{}) {}
|
||||
Reference in New Issue
Block a user