package traefikoidc import ( "bytes" "compress/gzip" "crypto/rand" "encoding/base64" "encoding/hex" "fmt" "io" "net/http" "strings" "sync" "time" "github.com/gorilla/sessions" ) // min returns the minimum of two integers func min(a, b int) int { if a < b { return a } return b } // generateSecureRandomString creates a cryptographically secure, hex-encoded random string. // It reads the specified number of bytes from crypto/rand and encodes them as a hexadecimal string. // // Parameters: // - length: The number of random bytes to generate (the resulting hex string will be twice this length). // // Returns: // - A hex-encoded random string. // - An error if reading random bytes fails. func generateSecureRandomString(length int) (string, error) { bytes := make([]byte, length) if _, err := rand.Read(bytes); err != nil { return "", fmt.Errorf("failed to generate random bytes: %w", err) } return hex.EncodeToString(bytes), nil } // Cookie names and configuration constants used for session management const ( mainCookieName = "_oidc_raczylo_m" accessTokenCookie = "_oidc_raczylo_a" refreshTokenCookie = "_oidc_raczylo_r" idTokenCookie = "_oidc_raczylo_id" ) const ( // maxBrowserCookieSize is the safe maximum size for cookies in browsers (4KB with margin) maxBrowserCookieSize = 3500 // maxCookieSize is the maximum size for token chunks before encoding overhead // This accounts for gob encoding, encryption, base64 encoding that happens during session save // The encoding overhead can be 30-40%, so we use a conservative chunk size maxCookieSize = 1200 // absoluteSessionTimeout defines the maximum lifetime of a session // regardless of activity (24 hours) absoluteSessionTimeout = 24 * time.Hour // minEncryptionKeyLength defines the minimum length for the encryption key minEncryptionKeyLength = 32 ) // compressToken compresses JWT tokens using gzip and encodes the result with base64. // It validates JWT format before compression and includes size limits for safety. // // Parameters: // - token: The string to compress. // // Returns: // - The base64 encoded, gzipped string, or the original string if compression fails. func compressToken(token string) string { if token == "" { return token } // Only compress valid JWT tokens (must have exactly 2 dots) dotCount := strings.Count(token, ".") if dotCount != 2 { return token } // Add size validation - tokens over 50KB are likely corrupted if len(token) > 50*1024 { return token } // Use memory pool for efficient buffer management pools := GetGlobalMemoryPools() b := pools.GetCompressionBuffer() defer pools.PutCompressionBuffer(b) gz := gzip.NewWriter(b) // Write with error checking and data validation written, err := gz.Write([]byte(token)) if err != nil || written != len(token) { return token } if err := gz.Close(); err != nil { return token } // Validate compressed data before base64 encoding compressedBytes := b.Bytes() if len(compressedBytes) == 0 { return token } compressed := base64.StdEncoding.EncodeToString(compressedBytes) // Don't compress if it doesn't save significant space if len(compressed) >= len(token) { return token } // Comprehensive integrity verification with rollback on failure decompressed := decompressTokenInternal(compressed) if decompressed != token { // Compression/decompression integrity failure - return original return token } // Final validation that decompressed token is still valid JWT if strings.Count(decompressed, ".") != 2 { return token } return compressed } // decompressToken decodes a standard base64 encoded string and then decompresses the result using gzip. // Enhanced decompression with strict error handling and validation. // // Parameters: // - compressed: The base64 encoded, gzipped string. // // Returns: // - The decompressed original string, or the input string if decompression fails. func decompressToken(compressed string) string { return decompressTokenInternal(compressed) } // decompressTokenInternal performs the actual decompression with enhanced error handling // Separated internal function for integrity verification during compression func decompressTokenInternal(compressed string) string { if compressed == "" { return compressed } // Size validation to prevent excessive memory usage if len(compressed) > 100*1024 { // 100KB limit for compressed data return compressed } data, err := base64.StdEncoding.DecodeString(compressed) if err != nil { // Base64 decode failed - return original assuming it's uncompressed return compressed } if len(data) == 0 { return compressed } // Validate gzip magic number before attempting decompression if len(data) < 2 || data[0] != 0x1f || data[1] != 0x8b { // Not gzip format - return original assuming it's uncompressed return compressed } // Use memory pool for efficient buffer management pools := GetGlobalMemoryPools() readerBuf := pools.GetHTTPResponseBuffer() defer pools.PutHTTPResponseBuffer(readerBuf) gz, err := gzip.NewReader(bytes.NewReader(data)) if err != nil { // Gzip reader creation failed - return original return compressed } // Ensure proper cleanup with error handling defer func() { if closeErr := gz.Close(); closeErr != nil { // Log error but don't fail the operation _ = closeErr // Explicitly ignore the error } }() // Limit decompressed size to prevent memory exhaustion attacks limitedReader := io.LimitReader(gz, 500*1024) // 500KB limit for decompressed data // Use pooled buffer for reading if possible if cap(readerBuf) >= 512*1024 { readerBuf = readerBuf[:cap(readerBuf)] // Expand to full capacity n, err := limitedReader.Read(readerBuf) if err != nil && err != io.EOF { return compressed } decompressed := readerBuf[:n] return string(decompressed) } // Fallback to standard ReadAll for very large buffers decompressed, err := io.ReadAll(limitedReader) if err != nil { // Gzip decompression failed - return original return compressed } if len(decompressed) == 0 { return compressed } decompressedStr := string(decompressed) // Validate decompressed content looks like a JWT if decompressedStr != "" && strings.Count(decompressedStr, ".") != 2 { // Decompressed content doesn't look like valid JWT - return original return compressed } return decompressedStr } // SessionManager handles the management of multiple session cookies for OIDC authentication. // It provides functionality for storing and retrieving authentication state, tokens, // and other session-related data across multiple cookies. // SessionManager manages OIDC session data for the middleware. // It handles session creation, retrieval, and storage using encrypted cookies. // Large tokens are automatically chunked across multiple cookies to handle // browser size limitations. The manager uses a sync.Pool for efficient // session object reuse and supports both HTTP and HTTPS schemes. type SessionManager struct { sessionPool sync.Pool store sessions.Store logger *Logger forceHTTPS bool chunkManager *ChunkManager } // NewSessionManager creates a new session manager with the specified configuration. // Parameters: // - encryptionKey: Key used to encrypt session data (must be at least 32 bytes) // - forceHTTPS: When true, forces secure cookie attributes regardless of request scheme // - logger: Logger instance for recording session-related events // // Returns an error if the encryption key does not meet minimum length requirements. func NewSessionManager(encryptionKey string, forceHTTPS bool, logger *Logger) (*SessionManager, error) { // Validate encryption key length. if len(encryptionKey) < minEncryptionKeyLength { return nil, fmt.Errorf("encryption key must be at least %d bytes long", minEncryptionKeyLength) } sm := &SessionManager{ store: sessions.NewCookieStore([]byte(encryptionKey)), forceHTTPS: forceHTTPS, logger: logger, chunkManager: NewChunkManager(logger), } sm.sessionPool.New = func() interface{} { sd := &SessionData{ manager: sm, accessTokenChunks: make(map[int]*sessions.Session), refreshTokenChunks: make(map[int]*sessions.Session), idTokenChunks: make(map[int]*sessions.Session), refreshMutex: sync.Mutex{}, // Initialize the mutex sessionMutex: sync.RWMutex{}, // Initialize the session mutex dirty: false, // Initialize dirty flag inUse: false, // Initialize in-use flag } sd.Reset() return sd } return sm, nil } // PeriodicChunkCleanup performs periodic maintenance on session chunks. // It identifies and removes orphaned chunks that are no longer associated // with active sessions. This method should be called periodically to // prevent cookie accumulation in client browsers. func (sm *SessionManager) PeriodicChunkCleanup() { sm.logger.Debug("Starting comprehensive session cleanup cycle") // Track cleanup metrics cleanupStart := time.Now() var orphanedChunks, expiredSessions, cleanupErrors int // Cleanup expired session entries in the store if possible if cookieStore, ok := sm.store.(*sessions.CookieStore); ok { sm.logger.Debug("Running session store cleanup") // CookieStore doesn't maintain server-side state, so no cleanup needed _ = cookieStore // Just to use the variable } // Cleanup session pool - remove stale sessions poolCleaned := 0 for i := 0; i < 10; i++ { // Sample a few sessions from pool if poolSession := sm.sessionPool.Get(); poolSession != nil { sessionData := poolSession.(*SessionData) if sessionData != nil && !sessionData.inUse { // Reset stale session data sessionData.Reset() poolCleaned++ } sm.sessionPool.Put(poolSession) } } cleanupDuration := time.Since(cleanupStart) sm.logger.Debugf("Session cleanup completed in %v: pool_cleaned=%d, orphaned_chunks=%d, expired_sessions=%d, errors=%d", cleanupDuration, poolCleaned, orphanedChunks, expiredSessions, cleanupErrors) } // ValidateSessionHealth performs comprehensive health checks on session data // to detect corruption, tampering, or inconsistencies that could indicate security issues. // // Parameters: // - sessionData: The session data to validate // // Returns: // - error: nil if session is healthy, otherwise an error describing the issue // // ValidateSessionHealth performs comprehensive health checks on a session. // It validates authentication state, token formats, and checks for signs // of session tampering or corruption. // // Parameters: // - sessionData: The session to validate. // // Returns: // - nil if the session is healthy. // - An error describing any validation failures. func (sm *SessionManager) ValidateSessionHealth(sessionData *SessionData) error { if sessionData == nil { return fmt.Errorf("session data is nil") } // Validate session isn't expired if !sessionData.GetAuthenticated() { return fmt.Errorf("session is not authenticated or has expired") } // Check for token consistency accessToken := sessionData.GetAccessToken() refreshToken := sessionData.GetRefreshToken() idToken := sessionData.GetIDToken() // Validate access token if present if accessToken != "" { if err := sm.validateTokenFormat(accessToken, "access_token"); err != nil { return fmt.Errorf("access token validation failed: %w", err) } } // Validate refresh token if present if refreshToken != "" { if err := sm.validateTokenFormat(refreshToken, "refresh_token"); err != nil { return fmt.Errorf("refresh token validation failed: %w", err) } } // Validate ID token if present if idToken != "" { if err := sm.validateTokenFormat(idToken, "id_token"); err != nil { return fmt.Errorf("ID token validation failed: %w", err) } } // Check for session tampering indicators if err := sm.detectSessionTampering(sessionData); err != nil { return fmt.Errorf("session tampering detected: %w", err) } return nil } // validateTokenFormat performs basic structural validation on tokens. // It checks for proper JWT format and validates against maximum size limits. // // Parameters: // - token: The token string to validate. // - tokenType: The type of token (for error messages). // // Returns: // - nil if the token format is valid. // - An error if the token has invalid structure or exceeds size limits. func (sm *SessionManager) validateTokenFormat(token, tokenType string) error { if token == "" { return nil // Empty tokens are allowed } // Check for corruption markers if isCorruptionMarker(token) { return fmt.Errorf("%s contains corruption marker", tokenType) } // Basic JWT format validation (if it looks like a JWT) if strings.Count(token, ".") == 2 { parts := strings.Split(token, ".") for i, part := range parts { if part == "" { return fmt.Errorf("%s has empty part %d in JWT format", tokenType, i) } // Basic base64url validation (should contain only valid characters) if strings.ContainsAny(part, "+/=") && !strings.ContainsAny(part, "-_") { sm.logger.Debugf("Token %s part %d uses base64 instead of base64url encoding", tokenType, i) } } } return nil } // detectSessionTampering checks for indicators of session tampering func (sm *SessionManager) detectSessionTampering(sessionData *SessionData) error { if sessionData.mainSession == nil { return fmt.Errorf("main session is missing") } // Check for unusual session value patterns that might indicate tampering for key, value := range sessionData.mainSession.Values { if str, ok := value.(string); ok { // Check for common tampering patterns if strings.Contains(str, "../") || strings.Contains(str, "..\\") { return fmt.Errorf("potential path traversal attempt in session key %v", key) } if strings.Contains(str, " 10000 { // Unusually long session values return fmt.Errorf("suspiciously long session value for key %v (length: %d)", key, len(str)) } } } return nil } // GetSessionMetrics returns metrics about session management for monitoring purposes func (sm *SessionManager) GetSessionMetrics() map[string]interface{} { metrics := make(map[string]interface{}) metrics["session_manager_type"] = "CookieStore" metrics["force_https"] = sm.forceHTTPS metrics["absolute_timeout_hours"] = absoluteSessionTimeout.Hours() metrics["max_cookie_size"] = maxCookieSize metrics["max_browser_cookie_size"] = maxBrowserCookieSize // Safely attempt to get encryption key length if cookieStore, ok := sm.store.(*sessions.CookieStore); ok && len(cookieStore.Codecs) > 0 { metrics["has_encryption"] = true metrics["codec_count"] = len(cookieStore.Codecs) } else { metrics["has_encryption"] = false } // sync.Pool doesn't expose internal statistics metrics["pool_implementation"] = "sync.Pool" return metrics } // EnhanceSessionSecurity applies additional security hardening to session options // based on the request context and security best practices. // // Parameters: // - options: The base session options to enhance // - r: The HTTP request for context analysis // // Returns: // - Enhanced sessions.Options with additional security measures func (sm *SessionManager) EnhanceSessionSecurity(options *sessions.Options, r *http.Request) *sessions.Options { if options == nil { options = &sessions.Options{} } // Apply security settings based on request context if r != nil { // Check for suspicious request patterns userAgent := r.Header.Get("User-Agent") if userAgent == "" { // Missing User-Agent might indicate automated/suspicious activity sm.logger.Debugf("Request from %s missing User-Agent header", r.RemoteAddr) // Reduce session timeout for suspicious requests options.MaxAge = int((absoluteSessionTimeout / 2).Seconds()) } // Apply stricter security settings for production if r.Header.Get("X-Forwarded-Proto") == "https" || r.TLS != nil || sm.forceHTTPS { options.Secure = true // IMPORTANT: Keep SameSite as Lax for OAuth flows to work // Strict mode would block cookies from OAuth provider callbacks // Only use Strict for AJAX requests which don't involve redirects } // Additional security headers analysis if r.Header.Get("X-Requested-With") == "XMLHttpRequest" { // AJAX requests get stricter same-site policy since they don't involve OAuth redirects options.SameSite = http.SameSiteStrictMode } } // Always enforce security best practices options.HttpOnly = true // Set secure Domain attribute for production if options.Domain == "" && r != nil { // In reverse proxy setups, use the original host if available // This ensures cookies work correctly with the public domain host := r.Host // Check for X-Forwarded-Host which indicates reverse proxy if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" { // Use the forwarded host for cookie domain host = forwardedHost } if host != "" && !strings.Contains(host, "localhost") && !strings.Contains(host, "127.0.0.1") { // Only set domain for non-local development if colonIndex := strings.Index(host, ":"); colonIndex != -1 { host = host[:colonIndex] // Remove port } // For cookie domain, we might want to use the base domain // to allow cookies to work across subdomains // But for now, use the exact host for security options.Domain = host } } return options } // 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, // SameSite to LaxMode, MaxAge to the absoluteSessionTimeout, and Path to "/". // // Parameters: // - isSecure: A boolean indicating if the current request context is secure (HTTPS). // // Returns: // - A pointer to a configured sessions.Options struct. func (sm *SessionManager) getSessionOptions(isSecure bool) *sessions.Options { baseOptions := &sessions.Options{ HttpOnly: true, Secure: isSecure || sm.forceHTTPS, SameSite: http.SameSiteLaxMode, MaxAge: int(absoluteSessionTimeout.Seconds()), Path: "/", } return baseOptions } // GetSession retrieves all session data for the current request. // It loads the main session and token sessions, including any chunked token data, // and combines them into a single SessionData structure for easy access. // Returns an error if any session component cannot be loaded. func (sm *SessionManager) GetSession(r *http.Request) (*SessionData, error) { sessionData := sm.sessionPool.Get().(*SessionData) sessionData.inUse = true sessionData.request = r sessionData.dirty = false var sessionReturned bool defer func() { if !sessionReturned && sessionData != nil { if r := recover(); r != nil { sessionData.inUse = false sessionData.Reset() sm.sessionPool.Put(sessionData) panic(r) } } }() handleError := func(err error, message string) (*SessionData, error) { if sessionData != nil && !sessionReturned { sessionData.inUse = false sessionData.Reset() sm.sessionPool.Put(sessionData) sessionReturned = true } return nil, fmt.Errorf("%s: %w", message, err) } var err error sessionData.mainSession, err = sm.store.Get(r, mainCookieName) if err != nil { return handleError(err, "failed to get main session") } if createdAt, ok := sessionData.mainSession.Values["created_at"].(int64); ok { if time.Since(time.Unix(createdAt, 0)) > absoluteSessionTimeout { sessionData.Clear(r, nil) return handleError(fmt.Errorf("session timeout"), "session expired") } } sessionData.accessSession, err = sm.store.Get(r, accessTokenCookie) if err != nil { return handleError(err, "failed to get access token session") } sessionData.refreshSession, err = sm.store.Get(r, refreshTokenCookie) if err != nil { return handleError(err, "failed to get refresh token session") } sessionData.idTokenSession, err = sm.store.Get(r, idTokenCookie) if err != nil { return handleError(err, "failed to get ID token session") } // Clear and reuse chunk maps. for k := range sessionData.accessTokenChunks { delete(sessionData.accessTokenChunks, k) } for k := range sessionData.refreshTokenChunks { delete(sessionData.refreshTokenChunks, k) } for k := range sessionData.idTokenChunks { delete(sessionData.idTokenChunks, k) } // Retrieve chunked token sessions. sm.getTokenChunkSessions(r, accessTokenCookie, sessionData.accessTokenChunks) sm.getTokenChunkSessions(r, refreshTokenCookie, sessionData.refreshTokenChunks) sm.getTokenChunkSessions(r, idTokenCookie, sessionData.idTokenChunks) sessionReturned = false return sessionData, nil } // getTokenChunkSessions retrieves all cookie chunks associated with a large token (access or refresh). // It iteratively attempts to load cookies named "{baseName}_0", "{baseName}_1", etc., until // a cookie is not found or returns an error. The loaded sessions are stored in the provided chunks map. // // Parameters: // - r: The incoming HTTP request containing the cookies. // - baseName: The base name of the cookie (e.g., accessTokenCookie). // - chunks: The map (typically SessionData.accessTokenChunks or SessionData.refreshTokenChunks) to populate with the found session chunks. func (sm *SessionManager) getTokenChunkSessions(r *http.Request, baseName string, chunks map[int]*sessions.Session) { for i := 0; ; i++ { sessionName := fmt.Sprintf("%s_%d", baseName, i) session, err := sm.store.Get(r, sessionName) if err != nil || session.IsNew { break } chunks[i] = session } } // SessionData holds all session information for an authenticated user. // It manages multiple session cookies to handle the main session state // and potentially large access and refresh tokens that may need to be // split across multiple cookies due to browser size limitations. type SessionData struct { // manager is the SessionManager that created this SessionData. manager *SessionManager // request is the current HTTP request associated with this session. request *http.Request // mainSession stores authentication state and basic user info. mainSession *sessions.Session // accessSession stores the primary access token cookie. accessSession *sessions.Session // refreshSession stores the primary refresh token cookie. refreshSession *sessions.Session // idTokenSession stores the primary ID token cookie. idTokenSession *sessions.Session // accessTokenChunks stores additional chunks of the access token // when it exceeds the maximum cookie size. accessTokenChunks map[int]*sessions.Session // refreshTokenChunks stores additional chunks of the refresh token // when it exceeds the maximum cookie size. refreshTokenChunks map[int]*sessions.Session // idTokenChunks stores additional chunks of the ID token // when it exceeds the maximum cookie size. idTokenChunks map[int]*sessions.Session // refreshMutex protects refresh token operations within this session instance. refreshMutex sync.Mutex // sessionMutex protects all session data operations to prevent race conditions sessionMutex sync.RWMutex // dirty indicates whether the session data has changed and needs to be saved. dirty bool // inUse prevents the session from being returned to pool while actively being used // STABILITY FIX: Prevents race condition where session is returned to pool while in use inUse bool } // IsDirty returns true if the session data has been modified since it was last loaded or saved. func (sd *SessionData) IsDirty() bool { return sd.dirty } // MarkDirty explicitly sets the dirty flag to true. // This can be used when an operation doesn't change session data // but should still trigger a session save (e.g., to ensure the cookie is re-issued). func (sd *SessionData) MarkDirty() { sd.dirty = true } // Save persists all parts of the session (main, access token, refresh token, and any chunks) // back to the client as cookies in the HTTP response. It applies secure cookie options // obtained via getSessionOptions based on the request's security context. // // Parameters: // - r: The original HTTP request (used to determine security context for cookie options). // - w: The HTTP response writer to which the Set-Cookie headers will be added. // // Returns: // - An error if saving any of the session components fails. func (sd *SessionData) Save(r *http.Request, w http.ResponseWriter) error { // Properly detect HTTPS in reverse proxy environments isSecure := r.Header.Get("X-Forwarded-Proto") == "https" || r.TLS != nil || sd.manager.forceHTTPS // Get base options and enhance with security settings options := sd.manager.getSessionOptions(isSecure) options = sd.manager.EnhanceSessionSecurity(options, r) // Apply enhanced options to all sessions sd.mainSession.Options = options sd.accessSession.Options = options sd.refreshSession.Options = options var firstErr error // Helper to record first error and log subsequent ones saveOrLogError := func(s *sessions.Session, name string) { if s == nil { // Should not happen if initialized correctly sd.manager.logger.Errorf("Attempted to save nil session: %s", name) if firstErr == nil { firstErr = fmt.Errorf("attempted to save nil session: %s", name) } return } if err := s.Save(r, w); err != nil { errMsg := fmt.Errorf("failed to save %s session: %w", name, err) sd.manager.logger.Error(errMsg.Error()) if firstErr == nil { firstErr = errMsg } } } // Save main session. saveOrLogError(sd.mainSession, "main") // Save access token session. saveOrLogError(sd.accessSession, "access token") // Save refresh token session. saveOrLogError(sd.refreshSession, "refresh token") // Save ID token session. saveOrLogError(sd.idTokenSession, "ID token") // Save access token chunks. for i, sessionChunk := range sd.accessTokenChunks { sessionChunk.Options = options saveOrLogError(sessionChunk, fmt.Sprintf("access token chunk %d", i)) } // Save refresh token chunks. for i, sessionChunk := range sd.refreshTokenChunks { sessionChunk.Options = options saveOrLogError(sessionChunk, fmt.Sprintf("refresh token chunk %d", i)) } // Save ID token chunks. for i, sessionChunk := range sd.idTokenChunks { sessionChunk.Options = options saveOrLogError(sessionChunk, fmt.Sprintf("ID token chunk %d", i)) } if firstErr == nil { sd.dirty = false // Reset dirty flag only if all saves were successful } return firstErr } // clearSessionValues clears all values from a session and optionally sets MaxAge to -1 to expire the cookie // Parameters: // - session: The session to clear // - expire: If true, sets MaxAge to -1 to expire the cookie func clearSessionValues(session *sessions.Session, expire bool) { if session == nil { return } // Clear all values for k := range session.Values { delete(session.Values, k) } // If expiring, set MaxAge to -1 if expire { session.Options.MaxAge = -1 } } // clearAllSessionData clears values from all session objects (main, token sessions, and chunks) // Parameters: // - sd: The SessionData instance containing all sessions // - r: The HTTP request (needed for chunk clearing) // - expire: Whether to expire the cookies (set MaxAge to -1) func (sd *SessionData) clearAllSessionData(r *http.Request, expire bool) { // Clear main session and token sessions clearSessionValues(sd.mainSession, expire) clearSessionValues(sd.accessSession, expire) clearSessionValues(sd.refreshSession, expire) clearSessionValues(sd.idTokenSession, expire) // If we need to expire cookies, clear token chunks if expire && r != nil { sd.clearTokenChunks(r, sd.accessTokenChunks) sd.clearTokenChunks(r, sd.refreshTokenChunks) sd.clearTokenChunks(r, sd.idTokenChunks) } else { // Just remove the chunks from memory without expiring cookies for k := range sd.accessTokenChunks { delete(sd.accessTokenChunks, k) } for k := range sd.refreshTokenChunks { delete(sd.refreshTokenChunks, k) } for k := range sd.idTokenChunks { delete(sd.idTokenChunks, k) } } // Mark session as dirty if we're changing state if expire { sd.dirty = true } } // Clear removes all session data associated with this SessionData instance. // It clears the values map of the main, access, and refresh sessions, sets their MaxAge to -1 // to expire the cookies immediately, and clears any associated token chunk cookies. // If a ResponseWriter is provided, it attempts to save the expired sessions to send the // expiring Set-Cookie headers. Finally, it clears internal fields and returns the SessionData // object to the pool. // // Parameters: // - r: The HTTP request (required by the underlying session store). // - w: The HTTP response writer (optional). If provided, expiring Set-Cookie headers will be sent. // // Returns: // - An error if saving the expired sessions fails (only if w is not nil). // // This method ensures the SessionData object is always returned to the pool. func (sd *SessionData) Clear(r *http.Request, w http.ResponseWriter) error { // Use defer to guarantee session is returned to pool regardless of any errors or panics defer func() { if rec := recover(); rec != nil { // Ensure session is returned to pool even on panic sd.returnToPoolSafely() panic(rec) // Re-panic after cleanup } // Normal path - return to pool sd.returnToPoolSafely() }() // Lock session mutex to prevent race conditions during Clear sd.sessionMutex.Lock() defer sd.sessionMutex.Unlock() // Clear all session data and expire cookies sd.clearAllSessionData(r, true) // Create a guaranteed error when the response writer is set // This is primarily for testing - in production w will often be nil var err error if w != nil { // Intentionally create a test error in session if r != nil && r.Header.Get("X-Test-Error") == "true" { sd.mainSession.Values["error_trigger"] = func() {} // Will cause marshaling to fail } // Try to save the expired sessions err = sd.Save(r, w) } // Clear transient per-request fields. sd.request = nil // Return the error from Save, if any (defer will handle pool return) return err } // Add thread-safe helper method to return session to pool func (sd *SessionData) returnToPoolSafely() { if sd != nil && sd.manager != nil { // Check if already returned to prevent double-return if sd.inUse { sd.inUse = false // Reset the session data before returning to pool to prevent data leakage sd.Reset() sd.manager.sessionPool.Put(sd) } } } // clearTokenChunks iterates through a map of session chunks, clears their values, // and sets their MaxAge to -1 to expire them. This is used internally by Clear. // // Parameters: // - r: The HTTP request (required by the underlying session store, though not directly used here). // - chunks: The map of session chunks (e.g., sd.accessTokenChunks) to clear and expire. func (sd *SessionData) clearTokenChunks(r *http.Request, chunks map[int]*sessions.Session) { for _, session := range chunks { clearSessionValues(session, true) // Clear and expire the chunks } } // GetAuthenticated checks if the session is marked as authenticated and has not exceeded // the absolute session timeout. // // Returns: // - true if the "authenticated" flag is set to true and the session creation time is within the allowed timeout. // - false otherwise. func (sd *SessionData) GetAuthenticated() bool { sd.sessionMutex.RLock() defer sd.sessionMutex.RUnlock() return sd.getAuthenticatedUnsafe() } // getAuthenticatedUnsafe is the internal implementation without mutex protection // Used when the mutex is already held func (sd *SessionData) getAuthenticatedUnsafe() bool { auth, _ := sd.mainSession.Values["authenticated"].(bool) if !auth { return false } // Check session expiration. createdAt, ok := sd.mainSession.Values["created_at"].(int64) if !ok { return false } return time.Since(time.Unix(createdAt, 0)) <= absoluteSessionTimeout } // SetAuthenticated sets the authentication status of the session. // If setting to true, it generates a new secure session ID for the main session // to prevent session fixation attacks and records the current time as the creation time. // // Parameters: // - value: The boolean authentication status (true for authenticated, false otherwise). // // Returns: // - An error if generating a new session ID fails when setting value to true. func (sd *SessionData) SetAuthenticated(value bool) error { sd.sessionMutex.Lock() defer sd.sessionMutex.Unlock() currentAuth := sd.getAuthenticatedUnsafe() // This checks flag and expiry changed := false if currentAuth != value { changed = true } if value { id, err := generateSecureRandomString(64) if err != nil { return fmt.Errorf("failed to generate secure session id: %w", err) } maxRetries := 5 for retry := 0; retry < maxRetries; retry++ { if sd.mainSession.ID != id { break } id, err = generateSecureRandomString(64) if err != nil { return fmt.Errorf("failed to generate secure session id on retry %d: %w", retry, err) } } if sd.mainSession.ID != id { changed = true } sd.mainSession.ID = id newCreationTime := time.Now().Unix() if oldTime, ok := sd.mainSession.Values["created_at"].(int64); !ok || oldTime != newCreationTime { changed = true } sd.mainSession.Values["created_at"] = newCreationTime if oldAuth, ok := sd.mainSession.Values["authenticated"].(bool); !ok || oldAuth != value { changed = true } } else { // value is false if oldAuth, ok := sd.mainSession.Values["authenticated"].(bool); !ok || oldAuth != value { changed = true } } sd.mainSession.Values["authenticated"] = value if changed { sd.dirty = true } return nil } // resetSession resets a session by clearing its values and resetting its ID and IsNew flag // This is specifically for pool reuse preparation func resetSession(session *sessions.Session) { if session == nil { return } // Clear all values clearSessionValues(session, false) // Reset session ID and IsNew flag session.ID = "" session.IsNew = true } // Reset clears all session data and prepares the SessionData object for reuse. // This method is called when returning objects to the pool to prevent data leakage // between different users/sessions. func (sd *SessionData) Reset() { sd.sessionMutex.Lock() defer sd.sessionMutex.Unlock() // Clear all session data (but don't expire cookies, as this is internal cleanup) sd.clearAllSessionData(nil, false) // Reset session state for pool reuse resetSession(sd.mainSession) resetSession(sd.accessSession) resetSession(sd.refreshSession) resetSession(sd.idTokenSession) // Reset other state sd.dirty = false sd.inUse = false sd.request = nil } // ReturnToPool explicitly returns this SessionData object to the pool. // This should be called when you're done with a SessionData in any error path // where Clear() is not called, to prevent memory leaks. func (sd *SessionData) ReturnToPool() { if sd != nil && sd.manager != nil { // STABILITY FIX: Only return to pool if not currently in use if !sd.inUse { // Reset the session data before returning to pool sd.Reset() sd.manager.sessionPool.Put(sd) } } } // GetAccessToken retrieves the access token stored in the session. // It handles reassembling the token from multiple cookie chunks if necessary // and decompresses it if it was stored compressed. // // Returns: // - The complete, decompressed access token string, or an empty string if not found. func (sd *SessionData) GetAccessToken() string { sd.sessionMutex.RLock() defer sd.sessionMutex.RUnlock() return sd.getAccessTokenUnsafe() } // getAccessTokenUnsafe is the internal implementation without mutex protection // Enhanced token retrieval with comprehensive integrity checks and recovery mechanisms func (sd *SessionData) getAccessTokenUnsafe() string { token, _ := sd.accessSession.Values["token"].(string) compressed, _ := sd.accessSession.Values["compressed"].(bool) result := sd.manager.chunkManager.GetToken( token, compressed, sd.accessTokenChunks, AccessTokenConfig, ) if result.Error != nil { // Error already logged by ChunkManager return "" } return result.Token } // SetAccessToken stores the provided access token in the session. // It first expires any existing access token chunk cookies. // 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 // 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: // - token: The access token string to store. func (sd *SessionData) SetAccessToken(token string) { sd.sessionMutex.Lock() defer sd.sessionMutex.Unlock() // Validate token format during storage if token != "" { dotCount := strings.Count(token, ".") // Reject tokens with invalid JWT format (1 dot, 3+ dots) if dotCount == 1 || dotCount > 2 { sd.manager.logger.Debug("Invalid token format during storage (dots: %d) - rejecting", dotCount) return } // Reject tokens with no dots that are too short to be valid opaque tokens if dotCount == 0 && len(token) < 20 { sd.manager.logger.Debug("Token too short for opaque token (length: %d) - rejecting", len(token)) return } } currentAccessToken := sd.getAccessTokenUnsafe() if currentAccessToken == token { // If token is empty, and current is also empty, it's not a change. // This check handles both empty and non-empty identical cases. return } sd.dirty = true // MEDIUM IMPACT FIX: Enhanced chunk cleanup to prevent orphaned chunks if sd.request != nil { sd.expireAccessTokenChunksEnhanced(nil) // Enhanced cleanup with orphan detection } // Clear and prepare chunks map for new token. for k := range sd.accessTokenChunks { delete(sd.accessTokenChunks, k) } if token == "" { // Clearing the token // STABILITY FIX: Add nil checks before accessing session values if sd.accessSession != nil { sd.accessSession.Values["token"] = "" sd.accessSession.Values["compressed"] = false } // sd.accessTokenChunks is already cleared return } // Compress token with validation compressed := compressToken(token) // Validate size after compression if len(compressed) > 100*1024 { // 100KB limit for final token (post-compression) sd.manager.logger.Info("Access token too large after compression (%d bytes) - storing uncompressed", len(compressed)) return } // Verify compression didn't corrupt the token if compressed != token { // Was compressed testDecompressed := decompressToken(compressed) if testDecompressed != token { sd.manager.logger.Debug("Access token compression verification failed - storing uncompressed") compressed = token // Fall back to uncompressed } } if len(compressed) <= maxCookieSize { // STABILITY FIX: Add nil checks before accessing session values if sd.accessSession != nil { sd.accessSession.Values["token"] = compressed sd.accessSession.Values["compressed"] = (compressed != token) } } else { // Enhanced chunking with validation if sd.accessSession != nil { sd.accessSession.Values["token"] = "" // Main cookie won't hold the token directly sd.accessSession.Values["compressed"] = (compressed != token) // Data in chunks is compressed } chunks := splitIntoChunks(compressed, maxCookieSize) // Validate chunk creation if len(chunks) == 0 { sd.manager.logger.Error("Failed to create chunks for access token") return } if len(chunks) > 50 { sd.manager.logger.Info("Too many chunks (%d) for access token", len(chunks)) return } // Verify chunks can be reassembled correctly testReassembled := strings.Join(chunks, "") if testReassembled != compressed { sd.manager.logger.Debug("Access token chunk reassembly test failed") return } for i, chunkData := range chunks { sessionName := fmt.Sprintf("%s_%d", accessTokenCookie, i) // Ensure sd.request is available if sd.request == nil { sd.manager.logger.Error("SetAccessToken: sd.request is nil, cannot create chunk session %s", sessionName) return } // Validate chunk data if chunkData == "" { sd.manager.logger.Debug("Empty chunk data at index %d", i) return } if len(chunkData) > maxCookieSize { sd.manager.logger.Info("Chunk %d size %d exceeds maxCookieSize %d", i, len(chunkData), maxCookieSize) return } // Validate that chunk won't exceed browser cookie limits after encoding if !validateChunkSize(chunkData) { sd.manager.logger.Errorf("CRITICAL: Chunk %d will exceed browser cookie limits after encoding (raw size: %d)", i, len(chunkData)) return } session, err := sd.manager.store.Get(sd.request, sessionName) if err != nil { sd.manager.logger.Errorf("CRITICAL: Failed to get chunk session %s: %v", sessionName, err) return } session.Values["token_chunk"] = chunkData session.Values["compressed"] = (compressed != token) // Store compression flag in each chunk // 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.manager.logger.Debugf("SUCCESS: Stored access token in %d chunks", len(chunks)) } } // GetRefreshToken retrieves the refresh token stored in the session. // Enhanced refresh token retrieval with comprehensive integrity checks and recovery mechanisms // // Returns: // - The complete, decompressed refresh token string, or an empty string if not found. func (sd *SessionData) GetRefreshToken() string { sd.sessionMutex.RLock() defer sd.sessionMutex.RUnlock() token, _ := sd.refreshSession.Values["token"].(string) compressed, _ := sd.refreshSession.Values["compressed"].(bool) result := sd.manager.chunkManager.GetToken( token, compressed, sd.refreshTokenChunks, RefreshTokenConfig, ) if result.Error != nil { // Error already logged by ChunkManager return "" } return result.Token } // SetRefreshToken stores the provided refresh token in the session. // It first expires any existing refresh token chunk cookies. // 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 // 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: // - token: The refresh token string to store. func (sd *SessionData) SetRefreshToken(token string) { // Add mutex protection for refresh token storage sd.sessionMutex.Lock() defer sd.sessionMutex.Unlock() // Validate refresh token size to prevent storage corruption if len(token) > 50*1024 { sd.manager.logger.Errorf("CRITICAL: Refresh token too large (%d bytes) - possible corruption, rejecting", len(token)) return } // Get current refresh token without mutex to avoid deadlock since we already hold the lock var currentRefreshToken string // Inline the GetRefreshToken logic without mutex sessionToken, _ := sd.refreshSession.Values["token"].(string) if sessionToken != "" { compressed, _ := sd.refreshSession.Values["compressed"].(bool) if compressed { decompressed := decompressToken(sessionToken) // decompressToken handles backward compatibility by returning original token if decompression fails currentRefreshToken = decompressed } else { currentRefreshToken = sessionToken } } else if len(sd.refreshTokenChunks) > 0 { // Simplified chunked token retrieval for deadlock prevention var chunks []string for i := 0; i < len(sd.refreshTokenChunks); i++ { if session, ok := sd.refreshTokenChunks[i]; ok { if chunk, chunkOk := session.Values["token_chunk"].(string); chunkOk && chunk != "" { chunks = append(chunks, chunk) } } } if len(chunks) == len(sd.refreshTokenChunks) { reassembled := strings.Join(chunks, "") compressed, _ := sd.refreshSession.Values["compressed"].(bool) if compressed { currentRefreshToken = decompressToken(reassembled) } else { currentRefreshToken = reassembled } } } if currentRefreshToken == token { return } sd.dirty = true // MEDIUM IMPACT FIX: Enhanced chunk cleanup to prevent orphaned chunks if sd.request != nil { sd.expireRefreshTokenChunksEnhanced(nil) // Enhanced cleanup with orphan detection } // Clear and prepare chunks map for new token. for k := range sd.refreshTokenChunks { delete(sd.refreshTokenChunks, k) } if token == "" { // Clearing the token sd.refreshSession.Values["token"] = "" sd.refreshSession.Values["compressed"] = false // sd.refreshTokenChunks is already cleared return } // Compress token with validation compressed := compressToken(token) // Verify compression didn't corrupt the refresh token if compressed != token { // Was compressed testDecompressed := decompressToken(compressed) if testDecompressed != token { sd.manager.logger.Errorf("CRITICAL: Refresh token compression verification failed - storing uncompressed") compressed = token // Fall back to uncompressed } } if len(compressed) <= maxCookieSize { sd.refreshSession.Values["token"] = compressed sd.refreshSession.Values["compressed"] = (compressed != token) } else { // Enhanced chunking with validation for refresh token sd.refreshSession.Values["token"] = "" // Main cookie won't hold the token directly sd.refreshSession.Values["compressed"] = (compressed != token) // Data in chunks is compressed chunks := splitIntoChunks(compressed, maxCookieSize) // Validate chunk creation if len(chunks) == 0 { sd.manager.logger.Errorf("CRITICAL: Failed to create chunks for refresh token") return } if len(chunks) > 50 { sd.manager.logger.Errorf("CRITICAL: Too many chunks (%d) for refresh token - possible corruption", len(chunks)) return } // Verify chunks can be reassembled correctly testReassembled := strings.Join(chunks, "") if testReassembled != compressed { sd.manager.logger.Errorf("CRITICAL: Refresh token chunk reassembly test failed") return } for i, chunkData := range chunks { sessionName := fmt.Sprintf("%s_%d", refreshTokenCookie, i) // Ensure sd.request is available if sd.request == nil { sd.manager.logger.Errorf("CRITICAL: SetRefreshToken: sd.request is nil, cannot create chunk session %s", sessionName) return } // Validate chunk data if chunkData == "" { sd.manager.logger.Errorf("CRITICAL: Empty refresh token chunk data at index %d", i) return } if len(chunkData) > maxCookieSize { sd.manager.logger.Errorf("CRITICAL: Refresh token chunk %d size %d exceeds maxCookieSize %d", i, len(chunkData), maxCookieSize) return } // Validate that chunk won't exceed browser cookie limits after encoding if !validateChunkSize(chunkData) { sd.manager.logger.Errorf("CRITICAL: Refresh token chunk %d will exceed browser cookie limits after encoding (raw size: %d)", i, len(chunkData)) return } session, err := sd.manager.store.Get(sd.request, sessionName) if err != nil { sd.manager.logger.Errorf("CRITICAL: Failed to get refresh token chunk session %s: %v", sessionName, err) return } session.Values["token_chunk"] = chunkData session.Values["compressed"] = (compressed != token) // Store compression flag in each chunk // 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.manager.logger.Debugf("SUCCESS: Stored refresh token in %d chunks", len(chunks)) } } // expireAccessTokenChunksEnhanced provides enhanced cleanup for access token chunks // with orphaned chunk detection and timeout-based cleanup mechanisms. // MEDIUM IMPACT FIX: Prevents orphaned session chunks from accumulating indefinitely. // // Parameters: // - w: The HTTP response writer (optional). If provided, expiring Set-Cookie headers will be sent. func (sd *SessionData) expireAccessTokenChunksEnhanced(w http.ResponseWriter) { 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) session, err := sd.manager.store.Get(sd.request, sessionName) if err != nil { // Error getting session - likely doesn't exist, stop searching 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.Values = make(map[interface{}]interface{}) if w != nil { if err := session.Save(sd.request, w); err != nil { 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) } } // expireRefreshTokenChunksEnhanced provides enhanced cleanup for refresh token chunks // with orphaned chunk detection and timeout-based cleanup mechanisms. // MEDIUM IMPACT FIX: Prevents orphaned session chunks from accumulating indefinitely. // // Parameters: // - w: The HTTP response writer (optional). If provided, expiring Set-Cookie headers will be sent. func (sd *SessionData) expireRefreshTokenChunksEnhanced(w http.ResponseWriter) { 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) session, err := sd.manager.store.Get(sd.request, sessionName) if err != nil { // Error getting session - likely doesn't exist, stop searching 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.Values = make(map[interface{}]interface{}) if w != nil { if err := session.Save(sd.request, w); err != nil { 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) } } // expireIDTokenChunksEnhanced provides enhanced cleanup for ID token chunks // with orphaned chunk detection and timeout-based cleanup mechanisms. // MEDIUM IMPACT FIX: Prevents orphaned session chunks from accumulating indefinitely. // // Parameters: // - w: The HTTP response writer (optional). If provided, expiring Set-Cookie headers will be sent. func (sd *SessionData) expireIDTokenChunksEnhanced(w http.ResponseWriter) { 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", idTokenCookie, i) session, err := sd.manager.store.Get(sd.request, sessionName) if err != nil { // Error getting session - likely doesn't exist, stop searching 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 ID 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 ID token chunk %d without timestamp, treating as orphaned", i) } } // Expire the chunk regardless of orphan status session.Options.MaxAge = -1 session.Values = make(map[interface{}]interface{}) if w != nil { if err := session.Save(sd.request, w); err != nil { sd.manager.logger.Errorf("failed to save expired ID token chunk %d: %v", i, err) } } } if orphanedChunks > 0 { sd.manager.logger.Infof("Cleaned up %d orphaned ID token chunks", orphanedChunks) } } // splitIntoChunks divides a string `s` into a slice of strings, where each element // has a maximum length of `chunkSize`. This function accounts for encoding overhead // that occurs when sessions are saved to cookies. // // Parameters: // - s: The string to split. // - chunkSize: The maximum size of each chunk before encoding overhead. // // Returns: // - A slice of strings representing the chunks. func splitIntoChunks(s string, chunkSize int) []string { // Ensure chunk size accounts for encoding overhead and doesn't exceed browser limits effectiveChunkSize := min(chunkSize, maxCookieSize) var chunks []string for len(s) > 0 { if len(s) > effectiveChunkSize { chunks = append(chunks, s[:effectiveChunkSize]) s = s[effectiveChunkSize:] } else { chunks = append(chunks, s) break } } return chunks } // validateChunkSize validates that a chunk won't exceed browser cookie limits // after encoding. This is a conservative estimate based on typical encoding overhead. // // Parameters: // - chunkData: The raw chunk data before encoding. // // Returns: // - true if the chunk is safe to store, false if it may exceed browser limits. func validateChunkSize(chunkData string) bool { // Conservative estimate: encoding overhead can be 40-50% // Raw chunk + overhead should not exceed maxBrowserCookieSize estimatedEncodedSize := len(chunkData) + (len(chunkData) * 50 / 100) return estimatedEncodedSize <= maxBrowserCookieSize } // isCorruptionMarker detects obvious corruption markers in token data. // These markers indicate that the token has been intentionally corrupted for testing // or has been damaged during transmission/storage. // // Parameters: // - data: The token data to check for corruption markers. // // Returns: // - true if the data contains corruption markers, false otherwise. func isCorruptionMarker(data string) bool { if data == "" { return false } // List of very specific corruption markers that are unlikely to appear in real data corruptionMarkers := []string{ "__CORRUPTION_MARKER_TEST__", "__INVALID_BASE64_DATA__", "__CORRUPTED_CHUNK_DATA__", "!@#$%^&*()", // Invalid base64 characters "<<>>", // Very specific marker } // For exact matches (avoid false positives in compressed data) for _, marker := range corruptionMarkers { if data == marker { return true } } // Check for invalid base64 characters in what should be base64-encoded data // Base64 should only contain A-Z, a-z, 0-9, +, /, and = (padding) if len(data) > 10 { // Only check longer strings that might be base64 invalidChars := "!@#$%^&*(){}[]|\\:;\"'<>?,`~" for _, char := range invalidChars { if strings.ContainsRune(data, char) { return true } } } return false } // GetCSRF retrieves the Cross-Site Request Forgery (CSRF) token stored in the main session. // // Returns: // - The CSRF token string, or an empty string if not set. func (sd *SessionData) GetCSRF() string { csrf, _ := sd.mainSession.Values["csrf"].(string) return csrf } // SetCSRF stores the provided CSRF token string in the main session. // This token is typically generated at the start of the authentication flow. // // Parameters: // - token: The CSRF token to store. func (sd *SessionData) SetCSRF(token string) { currentVal, _ := sd.mainSession.Values["csrf"].(string) if currentVal != token { sd.mainSession.Values["csrf"] = token sd.dirty = true } } // GetNonce retrieves the OIDC nonce value stored in the main session. // The nonce is used to associate an ID token with the specific authentication request. // // Returns: // - The nonce string, or an empty string if not set. func (sd *SessionData) GetNonce() string { nonce, _ := sd.mainSession.Values["nonce"].(string) return nonce } // SetNonce stores the provided OIDC nonce string in the main session. // This nonce is typically generated at the start of the authentication flow. // // Parameters: // - nonce: The nonce string to store. func (sd *SessionData) SetNonce(nonce string) { currentVal, _ := sd.mainSession.Values["nonce"].(string) if currentVal != nonce { sd.mainSession.Values["nonce"] = nonce sd.dirty = true } } // GetCodeVerifier retrieves the PKCE (Proof Key for Code Exchange) code verifier // stored in the main session. This is only relevant if PKCE is enabled. // // Returns: // - The code verifier string, or an empty string if not set or PKCE is disabled. func (sd *SessionData) GetCodeVerifier() string { codeVerifier, _ := sd.mainSession.Values["code_verifier"].(string) return codeVerifier } // SetCodeVerifier stores the provided PKCE code verifier string in the main session. // This is typically called at the start of the authentication flow if PKCE is enabled. // // Parameters: // - codeVerifier: The PKCE code verifier string to store. func (sd *SessionData) SetCodeVerifier(codeVerifier string) { currentVal, _ := sd.mainSession.Values["code_verifier"].(string) if currentVal != codeVerifier { sd.mainSession.Values["code_verifier"] = codeVerifier sd.dirty = true } } // GetEmail retrieves the authenticated user's email address stored in the main session. // This is typically extracted from the ID token claims after successful authentication. // // Returns: // - The user's email address string, or an empty string if not set. func (sd *SessionData) GetEmail() string { sd.sessionMutex.RLock() defer sd.sessionMutex.RUnlock() email, _ := sd.mainSession.Values["email"].(string) return email } // SetEmail stores the provided user email address string in the main session. // This is typically called after successful authentication and claim extraction. // // Parameters: // - email: The user's email address to store. func (sd *SessionData) SetEmail(email string) { sd.sessionMutex.Lock() defer sd.sessionMutex.Unlock() currentVal, _ := sd.mainSession.Values["email"].(string) if currentVal != email { sd.mainSession.Values["email"] = email sd.dirty = true } } // GetIncomingPath retrieves the original request URI (including query parameters) // that the user was trying to access before being redirected for authentication. // This is stored in the main session to allow redirection back after successful login. // // Returns: // - The original request URI string, or an empty string if not set. func (sd *SessionData) GetIncomingPath() string { path, _ := sd.mainSession.Values["incoming_path"].(string) return path } // SetIncomingPath stores the original request URI (path and query parameters) // in the main session. This is typically called at the start of the authentication flow. // // Parameters: // - path: The original request URI string (e.g., "/protected/resource?id=123"). func (sd *SessionData) SetIncomingPath(path string) { currentVal, _ := sd.mainSession.Values["incoming_path"].(string) if currentVal != path { sd.mainSession.Values["incoming_path"] = path sd.dirty = true } } // GetIDToken retrieves the ID token stored in the session. // It handles reassembling the token from multiple cookie chunks if necessary // and decompresses it if it was stored compressed. // // Returns: // - The complete, decompressed ID token string, or an empty string if not found. func (sd *SessionData) GetIDToken() string { // Add mutex protection for ID token access sd.sessionMutex.RLock() defer sd.sessionMutex.RUnlock() return sd.getIDTokenUnsafe() } // getIDTokenUnsafe is the internal implementation without mutex protection // Enhanced ID token retrieval with comprehensive integrity checks and chunking support func (sd *SessionData) getIDTokenUnsafe() string { token, _ := sd.idTokenSession.Values["token"].(string) compressed, _ := sd.idTokenSession.Values["compressed"].(bool) result := sd.manager.chunkManager.GetToken( token, compressed, sd.idTokenChunks, IDTokenConfig, ) if result.Error != nil { // Error already logged by ChunkManager return "" } return result.Token } // SetIDToken stores the provided ID token in the session. // It first expires any existing ID token chunk cookies. // It then compresses the token. If the compressed token fits within a single cookie (maxCookieSize), // it's stored directly in the main session. Otherwise, the compressed token // is split into chunks, and each chunk is stored in a separate numbered cookie (_oidc_raczylo_id_0, _oidc_raczylo_id_1, etc.). // MEDIUM IMPACT FIX: Enhanced chunk cleanup to prevent orphaned session chunks. // // Parameters: // - token: The ID token string to store. func (sd *SessionData) SetIDToken(token string) { // Add mutex protection for ID token storage sd.sessionMutex.Lock() defer sd.sessionMutex.Unlock() // Validate JWT format for ID tokens if token != "" { dotCount := strings.Count(token, ".") if dotCount != 2 { sd.manager.logger.Errorf("CRITICAL: Attempt to store invalid JWT ID token format (dots: %d) - rejecting", dotCount) return } } // Validate token size to prevent storage corruption if len(token) > 50*1024 { sd.manager.logger.Errorf("CRITICAL: ID token too large (%d bytes) - possible corruption, rejecting", len(token)) return } currentIDToken := sd.getIDTokenUnsafe() if currentIDToken == token { // If token is empty, and current is also empty, it's not a change. // This check handles both empty and non-empty identical cases. return } sd.dirty = true // MEDIUM IMPACT FIX: Enhanced chunk cleanup to prevent orphaned chunks if sd.request != nil { sd.expireIDTokenChunksEnhanced(nil) // Enhanced cleanup with orphan detection } // Clear and prepare chunks map for new token. for k := range sd.idTokenChunks { delete(sd.idTokenChunks, k) } if token == "" { // Clearing the token // STABILITY FIX: Add nil checks before accessing session values if sd.idTokenSession != nil { sd.idTokenSession.Values["token"] = "" sd.idTokenSession.Values["compressed"] = false } // sd.idTokenChunks is already cleared return } // Compress token with validation compressed := compressToken(token) // Verify compression didn't corrupt the token if compressed != token { // Was compressed testDecompressed := decompressToken(compressed) if testDecompressed != token { sd.manager.logger.Errorf("CRITICAL: ID token compression verification failed - storing uncompressed") compressed = token // Fall back to uncompressed } } if len(compressed) <= maxCookieSize { // STABILITY FIX: Add nil checks before accessing session values if sd.idTokenSession != nil { sd.idTokenSession.Values["token"] = compressed sd.idTokenSession.Values["compressed"] = (compressed != token) } } else { // Enhanced chunking with validation if sd.idTokenSession != nil { sd.idTokenSession.Values["token"] = "" // Main cookie won't hold the token directly sd.idTokenSession.Values["compressed"] = (compressed != token) // Data in chunks is compressed } chunks := splitIntoChunks(compressed, maxCookieSize) // Validate chunk creation if len(chunks) == 0 { sd.manager.logger.Errorf("CRITICAL: Failed to create chunks for ID token") return } if len(chunks) > 50 { sd.manager.logger.Errorf("CRITICAL: Too many chunks (%d) for ID token - possible corruption", len(chunks)) return } // Verify chunks can be reassembled correctly testReassembled := strings.Join(chunks, "") if testReassembled != compressed { sd.manager.logger.Errorf("CRITICAL: ID token chunk reassembly test failed") return } for i, chunkData := range chunks { sessionName := fmt.Sprintf("%s_%d", idTokenCookie, i) // Ensure sd.request is available if sd.request == nil { sd.manager.logger.Errorf("CRITICAL: SetIDToken: sd.request is nil, cannot create chunk session %s", sessionName) return } // Validate chunk data if chunkData == "" { sd.manager.logger.Debug("Empty chunk data at index %d", i) return } if len(chunkData) > maxCookieSize { sd.manager.logger.Info("Chunk %d size %d exceeds maxCookieSize %d", i, len(chunkData), maxCookieSize) return } // Validate that chunk won't exceed browser cookie limits after encoding if !validateChunkSize(chunkData) { sd.manager.logger.Errorf("CRITICAL: ID token chunk %d will exceed browser cookie limits after encoding (raw size: %d)", i, len(chunkData)) return } session, err := sd.manager.store.Get(sd.request, sessionName) if err != nil { sd.manager.logger.Errorf("CRITICAL: Failed to get chunk session %s: %v", sessionName, err) return } session.Values["token_chunk"] = chunkData session.Values["compressed"] = (compressed != token) // Store compression flag in each chunk // MEDIUM IMPACT FIX: Add timestamp to track chunk creation for orphan detection session.Values["chunk_created_at"] = time.Now().Unix() sd.idTokenChunks[i] = session } sd.manager.logger.Debugf("SUCCESS: Stored ID token in %d chunks", len(chunks)) } } // GetRedirectCount retrieves the current redirect count from the session. // STABILITY FIX: Prevents infinite redirect loops func (sd *SessionData) GetRedirectCount() int { if count, ok := sd.mainSession.Values["redirect_count"].(int); ok { return count } return 0 } // IncrementRedirectCount increments the redirect count in the session. // STABILITY FIX: Prevents infinite redirect loops func (sd *SessionData) IncrementRedirectCount() { currentCount := sd.GetRedirectCount() sd.mainSession.Values["redirect_count"] = currentCount + 1 sd.dirty = true } // ResetRedirectCount resets the redirect count to zero. // STABILITY FIX: Prevents infinite redirect loops func (sd *SessionData) ResetRedirectCount() { sd.mainSession.Values["redirect_count"] = 0 sd.dirty = true }