mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-13 04:07:37 +00:00
CSRF and Cookie Domain fixes
This commit is contained in:
@@ -77,6 +77,7 @@ testData:
|
||||
revocationURL: https://accounts.google.com/revoke # Endpoint for revoking tokens
|
||||
oidcEndSessionURL: https://accounts.google.com/logout # Provider's end session endpoint
|
||||
enablePKCE: false # Enables PKCE (Proof Key for Code Exchange) for additional security
|
||||
cookieDomain: "" # Explicit domain for session cookies (e.g., ".example.com" for multi-subdomain setups)
|
||||
|
||||
# --- Provider Specific Configuration Examples ---
|
||||
#
|
||||
@@ -373,6 +374,31 @@ configuration:
|
||||
Default: false
|
||||
required: false
|
||||
|
||||
cookieDomain:
|
||||
type: string
|
||||
description: |
|
||||
Explicit domain for session cookies. This is important for multi-subdomain setups
|
||||
and reverse proxy deployments to ensure consistent cookie handling.
|
||||
|
||||
When set, all session cookies will use this domain. When not set, the domain
|
||||
is auto-detected from the request headers (X-Forwarded-Host or Host).
|
||||
|
||||
Use a leading dot for subdomain-wide cookies (e.g., ".example.com" allows
|
||||
cookies to be shared between app.example.com, api.example.com, etc.).
|
||||
|
||||
Use a specific domain for host-only cookies (e.g., "app.example.com" restricts
|
||||
cookies to that exact domain).
|
||||
|
||||
This setting is crucial to prevent authentication issues like "CSRF token missing
|
||||
in session" errors that can occur when cookies are created with inconsistent domains.
|
||||
|
||||
Examples:
|
||||
- ".example.com" - Allows all subdomains to share cookies
|
||||
- "app.example.com" - Restricts cookies to this specific host
|
||||
|
||||
Default: "" (auto-detected from request headers)
|
||||
required: false
|
||||
|
||||
headers:
|
||||
type: array
|
||||
description: |
|
||||
|
||||
@@ -82,6 +82,7 @@ The middleware supports the following configuration options:
|
||||
| `oidcEndSessionURL` | The provider's end session endpoint | auto-discovered | `https://accounts.google.com/logout` |
|
||||
| `enablePKCE` | Enables PKCE (Proof Key for Code Exchange) for authorization code flow | `false` | `true`, `false` |
|
||||
| `refreshGracePeriodSeconds` | Seconds before token expiry to attempt proactive refresh | `60` | `120` |
|
||||
| `cookieDomain` | Explicit domain for session cookies (important for multi-subdomain setups) | auto-detected | `.example.com`, `app.example.com` |
|
||||
| `headers` | Custom HTTP headers with templates that can access OIDC claims and tokens | none | See "Templated Headers" section |
|
||||
|
||||
## Scope Configuration
|
||||
@@ -311,6 +312,30 @@ spec:
|
||||
- developer
|
||||
```
|
||||
|
||||
### With Cookie Domain Configuration (Multi-Subdomain Setup)
|
||||
|
||||
```yaml
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: oidc-multi-subdomain
|
||||
namespace: traefik
|
||||
spec:
|
||||
plugin:
|
||||
traefikoidc:
|
||||
providerURL: https://accounts.google.com
|
||||
clientID: 1234567890.apps.googleusercontent.com
|
||||
clientSecret: your-client-secret
|
||||
sessionEncryptionKey: potato-secret-is-at-least-32-bytes-long
|
||||
callbackURL: /oauth2/callback
|
||||
logoutURL: /oauth2/logout
|
||||
cookieDomain: .example.com # Allows cookies to be shared across all subdomains
|
||||
scopes:
|
||||
- roles # Appended to defaults: ["openid", "profile", "email", "roles"]
|
||||
```
|
||||
|
||||
**Important**: The `cookieDomain` parameter is crucial when running behind a reverse proxy or when your application serves multiple subdomains. Without it, cookies may be created with inconsistent domains, leading to authentication issues like "CSRF token missing in session" errors.
|
||||
|
||||
### With Custom Logging and Rate Limiting
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
// during the callback from Azure AD
|
||||
func TestAzureOAuthCallbackScenario(t *testing.T) {
|
||||
t.Run("Azure_OAuth_Complete_Flow", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 1: User visits https://app.example.com/protected
|
||||
@@ -110,7 +110,7 @@ func TestAzureOAuthCallbackScenario(t *testing.T) {
|
||||
|
||||
t.Run("Cookie_Not_Sent_With_Wrong_Domain", func(t *testing.T) {
|
||||
// This test verifies that cookies with wrong domain won't be sent
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Initial request sets cookie for app.example.com
|
||||
@@ -148,7 +148,7 @@ func TestAzureOAuthCallbackScenario(t *testing.T) {
|
||||
// This test demonstrates why we can't use SameSite=Strict for OAuth
|
||||
// With Strict, cookies wouldn't be sent when redirecting from Azure to our app
|
||||
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// If we had SameSite=Strict (which we don't anymore), the browser would:
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ func TestAzureOIDCRegression(t *testing.T) {
|
||||
tOidc := &mockTraefikOidc{TraefikOidc: baseOidc}
|
||||
|
||||
// Initialize session manager
|
||||
sessionManager, _ := NewSessionManager("test-encryption-key-32-bytes-long", false, mockLogger)
|
||||
sessionManager, _ := NewSessionManager("test-encryption-key-32-bytes-long", false, "", mockLogger)
|
||||
tOidc.sessionManager = sessionManager
|
||||
|
||||
// Mock the JWT verification to avoid JWKS lookup issues
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCookieCleanupBehavior tests the cookie cleanup function's behavior
|
||||
func TestCookieCleanupBehavior(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
|
||||
// Create session manager with a specific domain
|
||||
sm, err := NewSessionManager("test-encryption-key-32-characters", false, "app.example.com", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
|
||||
// Create request from app.example.com
|
||||
req := httptest.NewRequest("GET", "http://app.example.com/test", nil)
|
||||
|
||||
// Add some OIDC cookies (browsers don't send domain info)
|
||||
cookies := []*http.Cookie{
|
||||
{
|
||||
Name: "_oidc_raczylo_m",
|
||||
Value: "test-value-1",
|
||||
},
|
||||
{
|
||||
Name: "_oidc_raczylo_a",
|
||||
Value: "test-value-2",
|
||||
},
|
||||
{
|
||||
Name: "access_token_chunk_0",
|
||||
Value: "chunk-value",
|
||||
},
|
||||
}
|
||||
|
||||
for _, cookie := range cookies {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
|
||||
// Create response recorder to capture Set-Cookie headers
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
// Run cleanup - it should attempt to delete cookies with various domains
|
||||
sm.CleanupOldCookies(rr, req)
|
||||
|
||||
// Check Set-Cookie headers
|
||||
setCookies := rr.Result().Cookies()
|
||||
|
||||
// The cleanup should have attempted to delete cookies with various domain variations
|
||||
// We should see deletion attempts for:
|
||||
// - app.example.com
|
||||
// - .app.example.com
|
||||
// - example.com
|
||||
// - .example.com
|
||||
var deletionAttempts int
|
||||
domainsAttempted := make(map[string]bool)
|
||||
|
||||
for _, cookie := range setCookies {
|
||||
if cookie.MaxAge == -1 {
|
||||
deletionAttempts++
|
||||
domainsAttempted[cookie.Domain] = true
|
||||
}
|
||||
}
|
||||
|
||||
// We should see deletion attempts for various domain variations
|
||||
// Note: The exact number depends on the implementation, but we should see multiple attempts
|
||||
if deletionAttempts == 0 {
|
||||
t.Error("Expected cleanup to attempt cookie deletions, but none were found")
|
||||
}
|
||||
|
||||
// Log the domains attempted for debugging
|
||||
t.Logf("Deletion attempts: %d", deletionAttempts)
|
||||
for domain := range domainsAttempted {
|
||||
t.Logf("Attempted deletion for domain: %q", domain)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfiguredDomainPersistence tests that configured domain is consistently used
|
||||
func TestConfiguredDomainPersistence(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
|
||||
// Create session manager with explicit domain configuration
|
||||
configuredDomain := ".example.com"
|
||||
sm, err := NewSessionManager("test-encryption-key-32-characters", false, configuredDomain, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
|
||||
// Create requests from different subdomains
|
||||
tests := []struct {
|
||||
requestHost string
|
||||
name string
|
||||
}{
|
||||
{
|
||||
name: "Request from main domain",
|
||||
requestHost: "example.com",
|
||||
},
|
||||
{
|
||||
name: "Request from subdomain",
|
||||
requestHost: "app.example.com",
|
||||
},
|
||||
{
|
||||
name: "Request from nested subdomain",
|
||||
requestHost: "api.app.example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://"+tt.requestHost+"/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
// Get session and set a value
|
||||
session, err := sm.GetSession(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get session: %v", err)
|
||||
}
|
||||
defer session.ReturnToPool()
|
||||
|
||||
session.SetNonce("test-nonce")
|
||||
|
||||
// Save session
|
||||
err = session.Save(req, rr)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save session: %v", err)
|
||||
}
|
||||
|
||||
// Check that all cookies use the configured domain
|
||||
cookies := rr.Result().Cookies()
|
||||
for _, cookie := range cookies {
|
||||
if strings.HasPrefix(cookie.Name, "_oidc_") {
|
||||
// The domain should match the configured domain (with possible normalization)
|
||||
expectedDomains := []string{configuredDomain, strings.TrimPrefix(configuredDomain, ".")}
|
||||
domainMatches := false
|
||||
for _, expected := range expectedDomains {
|
||||
if cookie.Domain == expected || cookie.Domain == "" {
|
||||
domainMatches = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !domainMatches {
|
||||
t.Errorf("Cookie %s has unexpected domain %q, expected one of %v",
|
||||
cookie.Name, cookie.Domain, expectedDomains)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDomainMigration simulates migrating from no configured domain to explicit domain
|
||||
func TestDomainMigration(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
|
||||
// Step 1: Create session without configured domain (auto-detection)
|
||||
sm1, err := NewSessionManager("test-encryption-key-32-characters", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
|
||||
req1 := httptest.NewRequest("GET", "http://app.example.com/test", nil)
|
||||
rr1 := httptest.NewRecorder()
|
||||
|
||||
session1, err := sm1.GetSession(req1)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get session: %v", err)
|
||||
}
|
||||
|
||||
session1.SetNonce("test-nonce")
|
||||
session1.Save(req1, rr1)
|
||||
session1.ReturnToPool()
|
||||
|
||||
// The cookies will have auto-detected domain
|
||||
oldCookies := rr1.Result().Cookies()
|
||||
t.Logf("Old cookies count: %d", len(oldCookies))
|
||||
|
||||
// Step 2: Create new session manager with explicit domain configuration
|
||||
sm2, err := NewSessionManager("test-encryption-key-32-characters", false, ".example.com", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
|
||||
// Step 3: Make request with old cookies
|
||||
req2 := httptest.NewRequest("GET", "http://app.example.com/test", nil)
|
||||
|
||||
// Add the old cookies to the new request
|
||||
for _, cookie := range oldCookies {
|
||||
// Simulate browser behavior - don't include domain in Cookie header
|
||||
simpleCookie := &http.Cookie{
|
||||
Name: cookie.Name,
|
||||
Value: cookie.Value,
|
||||
}
|
||||
req2.AddCookie(simpleCookie)
|
||||
}
|
||||
|
||||
rr2 := httptest.NewRecorder()
|
||||
|
||||
// Run cleanup - should attempt to delete old cookies
|
||||
sm2.CleanupOldCookies(rr2, req2)
|
||||
|
||||
// Check that deletion cookies were sent
|
||||
newCookies := rr2.Result().Cookies()
|
||||
deletionCount := 0
|
||||
for _, cookie := range newCookies {
|
||||
if cookie.MaxAge == -1 {
|
||||
deletionCount++
|
||||
}
|
||||
}
|
||||
|
||||
if deletionCount == 0 {
|
||||
t.Error("Expected cleanup to send deletion cookies during migration, but none were found")
|
||||
}
|
||||
|
||||
t.Logf("Sent %d deletion cookies during migration", deletionCount)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCookieDomainConfiguration tests that the cookie domain configuration is properly applied
|
||||
func TestCookieDomainConfiguration(t *testing.T) {
|
||||
tests := []struct {
|
||||
configDomain string
|
||||
requestHost string
|
||||
forwardedHost string
|
||||
expectedDomain string
|
||||
name string
|
||||
}{
|
||||
{
|
||||
name: "Configured domain takes precedence",
|
||||
configDomain: ".example.com",
|
||||
requestHost: "app.example.com",
|
||||
expectedDomain: ".example.com",
|
||||
},
|
||||
{
|
||||
name: "Auto-detection when no domain configured",
|
||||
configDomain: "",
|
||||
requestHost: "app.example.com",
|
||||
expectedDomain: "app.example.com",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host used for auto-detection",
|
||||
configDomain: "",
|
||||
requestHost: "internal.local",
|
||||
forwardedHost: "public.example.com",
|
||||
expectedDomain: "public.example.com",
|
||||
},
|
||||
{
|
||||
name: "No domain for localhost",
|
||||
configDomain: "",
|
||||
requestHost: "localhost:8080",
|
||||
expectedDomain: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create session manager with configured domain
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("test-encryption-key-32-characters", false, tt.configDomain, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
|
||||
// Create request
|
||||
req := httptest.NewRequest("GET", "http://"+tt.requestHost+"/test", nil)
|
||||
if tt.forwardedHost != "" {
|
||||
req.Header.Set("X-Forwarded-Host", tt.forwardedHost)
|
||||
}
|
||||
|
||||
// Create a dummy response writer to test getCookieOptions behavior
|
||||
// We'll examine the session options domain instead
|
||||
options := &http.Cookie{
|
||||
Domain: sm.cookieDomain,
|
||||
}
|
||||
// If no configured domain, simulate auto-detection
|
||||
if sm.cookieDomain == "" && req != nil {
|
||||
host := req.Host
|
||||
if forwardedHost := req.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
|
||||
host = forwardedHost
|
||||
}
|
||||
if host != "" && !strings.Contains(host, "localhost") && !strings.Contains(host, "127.0.0.1") {
|
||||
if colonIndex := strings.Index(host, ":"); colonIndex != -1 {
|
||||
host = host[:colonIndex]
|
||||
}
|
||||
options.Domain = host
|
||||
}
|
||||
}
|
||||
|
||||
// Check domain
|
||||
if options.Domain != tt.expectedDomain {
|
||||
t.Errorf("Expected domain %q, got %q", tt.expectedDomain, options.Domain)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCookieDomainConsistency tests that all session cookies use the same domain
|
||||
func TestCookieDomainConsistency(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
|
||||
// Test with configured domain
|
||||
sm, err := NewSessionManager("test-encryption-key-32-characters", false, ".example.com", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "http://app.example.com/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
// Get session and set some values
|
||||
session, err := sm.GetSession(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get session: %v", err)
|
||||
}
|
||||
defer session.ReturnToPool()
|
||||
|
||||
// Set various session values including ID token
|
||||
session.SetNonce("test-nonce")
|
||||
session.SetAccessToken("test-access-token")
|
||||
session.SetRefreshToken("test-refresh-token")
|
||||
// Set a valid JWT-like ID token (needs 2 dots)
|
||||
session.SetIDToken("header.payload.signature")
|
||||
|
||||
// Save session
|
||||
err = session.Save(req, rr)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save session: %v", err)
|
||||
}
|
||||
|
||||
// Check all cookies have the same domain
|
||||
cookies := rr.Result().Cookies()
|
||||
var seenDomain string
|
||||
|
||||
for _, cookie := range cookies {
|
||||
// Only check our OIDC cookies
|
||||
if strings.HasPrefix(cookie.Name, "_oidc_") ||
|
||||
strings.HasPrefix(cookie.Name, "access_token_chunk_") ||
|
||||
strings.HasPrefix(cookie.Name, "refresh_token_chunk_") {
|
||||
|
||||
// Normalize domain for comparison (handle leading dot and empty domain)
|
||||
normalizedDomain := cookie.Domain
|
||||
if normalizedDomain == "" {
|
||||
// Empty domain means host-only cookie, should match configured domain
|
||||
normalizedDomain = "example.com"
|
||||
} else if strings.HasPrefix(normalizedDomain, ".") {
|
||||
normalizedDomain = normalizedDomain[1:]
|
||||
}
|
||||
|
||||
if seenDomain == "" {
|
||||
seenDomain = normalizedDomain
|
||||
} else if normalizedDomain != seenDomain {
|
||||
t.Errorf("Inconsistent cookie domains: %q vs %q for cookie %s",
|
||||
seenDomain, normalizedDomain, cookie.Name)
|
||||
}
|
||||
|
||||
// Verify it matches configured domain (browsers may normalize by removing leading dot)
|
||||
// Empty domain is also acceptable for host-only cookies
|
||||
if cookie.Domain != "" && cookie.Domain != ".example.com" && cookie.Domain != "example.com" {
|
||||
t.Errorf("Cookie %s has domain %q, expected %q, %q, or empty",
|
||||
cookie.Name, cookie.Domain, ".example.com", "example.com")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCookieDomainWithReverseProxy simulates a reverse proxy scenario
|
||||
func TestCookieDomainWithReverseProxy(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
|
||||
// No configured domain, should auto-detect from X-Forwarded-Host
|
||||
sm, err := NewSessionManager("test-encryption-key-32-characters", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
|
||||
// Simulate reverse proxy request
|
||||
req := httptest.NewRequest("GET", "http://internal.local:8080/test", nil)
|
||||
req.Header.Set("X-Forwarded-Host", "public.example.com")
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
|
||||
// Test the domain configuration
|
||||
// Since getCookieOptions is private, we'll check the configured domain directly
|
||||
options := &http.Cookie{
|
||||
Domain: sm.cookieDomain,
|
||||
}
|
||||
// If no configured domain, simulate auto-detection
|
||||
if sm.cookieDomain == "" && req != nil {
|
||||
host := req.Host
|
||||
if forwardedHost := req.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
|
||||
host = forwardedHost
|
||||
}
|
||||
if host != "" && !strings.Contains(host, "localhost") && !strings.Contains(host, "127.0.0.1") {
|
||||
if colonIndex := strings.Index(host, ":"); colonIndex != -1 {
|
||||
host = host[:colonIndex]
|
||||
}
|
||||
options.Domain = host
|
||||
}
|
||||
}
|
||||
|
||||
// Check secure flag based on X-Forwarded-Proto
|
||||
isSecure := req.Header.Get("X-Forwarded-Proto") == "https" || req.TLS != nil
|
||||
options.Secure = isSecure // Note: forceHTTPS is private so we can't access it in test
|
||||
|
||||
// Should use the forwarded host
|
||||
if options.Domain != "public.example.com" {
|
||||
t.Errorf("Expected domain from X-Forwarded-Host %q, got %q",
|
||||
"public.example.com", options.Domain)
|
||||
}
|
||||
|
||||
// Should be secure due to X-Forwarded-Proto
|
||||
if !options.Secure {
|
||||
t.Error("Expected Secure flag to be true with X-Forwarded-Proto: https")
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) {
|
||||
// Test that CSRF tokens persist through the authentication flow
|
||||
t.Run("CSRF_Token_Persists_After_Selective_Clear", func(t *testing.T) {
|
||||
// Create a session manager
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create initial request
|
||||
@@ -90,7 +90,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) {
|
||||
|
||||
// Test that marking session as dirty forces save
|
||||
t.Run("Mark_Dirty_Forces_Session_Save", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
@@ -126,7 +126,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) {
|
||||
|
||||
// Test Azure-specific session handling
|
||||
t.Run("Azure_Session_Cookie_Configuration", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate Azure callback scenario
|
||||
@@ -158,7 +158,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) {
|
||||
|
||||
// Test session continuity through auth flow
|
||||
t.Run("Session_Continuity_Through_Auth_Flow", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 1: Initial request
|
||||
@@ -199,7 +199,7 @@ func TestCSRFTokenSessionManagement(t *testing.T) {
|
||||
|
||||
// Test large token handling doesn't affect CSRF
|
||||
t.Run("Large_Tokens_Dont_Affect_CSRF", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
@@ -262,7 +262,7 @@ func TestAuthFlowWithoutExternalDependencies(t *testing.T) {
|
||||
|
||||
// We can't fully initialize TraefikOidc without network access,
|
||||
// but we can test the session management directly
|
||||
sessionManager, err := NewSessionManager(plugin.SessionEncryptionKey, plugin.ForceHTTPS, NewLogger(plugin.LogLevel))
|
||||
sessionManager, err := NewSessionManager(plugin.SessionEncryptionKey, plugin.ForceHTTPS, "", NewLogger(plugin.LogLevel))
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("Session_Created_On_Protected_Request", func(t *testing.T) {
|
||||
@@ -291,7 +291,7 @@ func TestAuthFlowWithoutExternalDependencies(t *testing.T) {
|
||||
// TestRegressionLoginLoop specifically tests the fix for issue #53
|
||||
func TestRegressionLoginLoop(t *testing.T) {
|
||||
// This test verifies that the specific changes made to fix the login loop work correctly
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate the exact flow that was causing the login loop
|
||||
@@ -392,7 +392,7 @@ func TestRegressionLoginLoop(t *testing.T) {
|
||||
|
||||
// TestCSRFValidationTiming tests timing-sensitive CSRF validation scenarios
|
||||
func TestCSRFValidationTiming(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("Rapid_Redirect_Maintains_CSRF", func(t *testing.T) {
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestGoogleOIDCRefreshTokenHandling(t *testing.T) {
|
||||
}
|
||||
|
||||
// Create a session manager
|
||||
sessionManager, _ := NewSessionManager("0123456789abcdef0123456789abcdef", true, mockLogger)
|
||||
sessionManager, _ := NewSessionManager("0123456789abcdef0123456789abcdef", true, "", mockLogger)
|
||||
tOidc.sessionManager = sessionManager
|
||||
|
||||
t.Run("Google provider detection adds required parameters", func(t *testing.T) {
|
||||
@@ -367,7 +367,7 @@ func TestGoogleOIDCRefreshTokenHandling(t *testing.T) {
|
||||
}
|
||||
|
||||
// Create a session manager
|
||||
sessionManager, _ := NewSessionManager("0123456789abcdef0123456789abcdef", true, mockLogger)
|
||||
sessionManager, _ := NewSessionManager("0123456789abcdef0123456789abcdef", true, "", mockLogger)
|
||||
googleTOidc.sessionManager = sessionManager
|
||||
|
||||
// Create a mock token verifier
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestIssue53Regression(t *testing.T) {
|
||||
// 3. Session cookies must be properly configured for HTTPS
|
||||
// 4. CSRF token must persist through the OAuth flow
|
||||
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 1: Initial request to protected resource
|
||||
@@ -108,7 +108,7 @@ func TestIssue53Regression(t *testing.T) {
|
||||
// This test ensures that once the session is properly maintained,
|
||||
// token signature verification works correctly for Azure tokens
|
||||
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create authenticated session with Azure tokens
|
||||
@@ -154,7 +154,7 @@ func TestIssue53Regression(t *testing.T) {
|
||||
// This test verifies the redirect loop prevention mechanism
|
||||
// that was added to handle authentication failures gracefully
|
||||
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("GET", "http://internal/protected", nil)
|
||||
@@ -183,7 +183,7 @@ func TestIssue53Regression(t *testing.T) {
|
||||
// in different reverse proxy scenarios
|
||||
func TestReverseProxySameSiteHandling(t *testing.T) {
|
||||
t.Run("SameSite_Lax_For_HTTPS_OAuth", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// HTTPS request via proxy
|
||||
@@ -211,7 +211,7 @@ func TestReverseProxySameSiteHandling(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("SameSite_Lax_For_HTTP", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Plain HTTP request (no proxy headers)
|
||||
|
||||
@@ -756,7 +756,7 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h
|
||||
suppressDiagnosticLogs: isTestMode(), // Suppress diagnostic logs during tests
|
||||
}
|
||||
|
||||
t.sessionManager, _ = NewSessionManager(config.SessionEncryptionKey, config.ForceHTTPS, t.logger)
|
||||
t.sessionManager, _ = NewSessionManager(config.SessionEncryptionKey, config.ForceHTTPS, config.CookieDomain, t.logger)
|
||||
t.errorRecoveryManager = NewErrorRecoveryManager(t.logger)
|
||||
t.extractClaimsFunc = extractClaims
|
||||
// t.exchangeCodeForTokenFunc = t.exchangeCodeForToken // Removed, using interface now
|
||||
@@ -1087,6 +1087,10 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up any old cookies with different domains before processing
|
||||
// This prevents CSRF token mismatch errors when cookie domain configuration changes
|
||||
t.sessionManager.CleanupOldCookies(rw, req)
|
||||
|
||||
// Retrieve or create session for request
|
||||
session, err := t.sessionManager.GetSession(req)
|
||||
if err != nil {
|
||||
|
||||
+4
-4
@@ -90,7 +90,7 @@ func (ts *TestSuite) Setup() {
|
||||
}
|
||||
|
||||
logger := NewLogger("info")
|
||||
ts.sessionManager, _ = NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger)
|
||||
ts.sessionManager, _ = NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger)
|
||||
|
||||
// Common TraefikOidc instance
|
||||
ts.tOidc = &TraefikOidc{
|
||||
@@ -1173,7 +1173,7 @@ func TestHandleCallback(t *testing.T) {
|
||||
ts.tOidc.tokenBlacklist = NewCache() // Use generic cache for blacklist
|
||||
|
||||
logger := NewLogger("info")
|
||||
sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger)
|
||||
sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger)
|
||||
|
||||
// Create a new instance for each test to avoid state carryover
|
||||
instanceExtractClaimsFunc := tc.extractClaimsFunc
|
||||
@@ -1561,7 +1561,7 @@ func TestHandleLogout(t *testing.T) {
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
logger := NewLogger("info")
|
||||
sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger)
|
||||
sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger)
|
||||
tOidc := &TraefikOidc{
|
||||
revocationURL: mockRevocationServer.URL,
|
||||
endSessionURL: tc.endSessionURL,
|
||||
@@ -1863,7 +1863,7 @@ func TestHandleExpiredToken(t *testing.T) {
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
logger := NewLogger("info")
|
||||
sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger)
|
||||
sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger)
|
||||
|
||||
tOidc := &TraefikOidc{
|
||||
sessionManager: sessionManager,
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@ func TestMemoryLeakFixes(t *testing.T) {
|
||||
|
||||
t.Run("Session pool returns properly", func(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("test-encryption-key-that-is-long-enough-32bytes", false, logger)
|
||||
sm, err := NewSessionManager("test-encryption-key-that-is-long-enough-32bytes", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// TestReverseProxyHTTPSDetection tests that HTTPS is properly detected in reverse proxy environments
|
||||
func TestReverseProxyHTTPSDetection(t *testing.T) {
|
||||
t.Run("HTTPS_Detection_With_X_Forwarded_Proto", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate request from reverse proxy (Traefik/nginx)
|
||||
@@ -48,7 +48,7 @@ func TestReverseProxyHTTPSDetection(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("HTTPS_Detection_Without_Headers", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Request without reverse proxy headers (direct HTTP)
|
||||
@@ -79,7 +79,7 @@ func TestReverseProxyHTTPSDetection(t *testing.T) {
|
||||
|
||||
t.Run("HTTPS_Detection_With_ForceHTTPS", func(t *testing.T) {
|
||||
// Test with forceHTTPS enabled
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", true, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", true, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Even without headers, forceHTTPS should make cookies secure
|
||||
@@ -110,7 +110,7 @@ func TestReverseProxyHTTPSDetection(t *testing.T) {
|
||||
// TestCSRFPersistenceInReverseProxy tests CSRF token persistence in reverse proxy setups
|
||||
func TestCSRFPersistenceInReverseProxy(t *testing.T) {
|
||||
t.Run("CSRF_Persists_Through_OAuth_Flow_With_Proxy", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 1: Initial request to protected resource (HTTPS via proxy)
|
||||
@@ -156,7 +156,7 @@ func TestCSRFPersistenceInReverseProxy(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Session_Cookie_Domain_With_Proxy_Headers", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test with X-Forwarded-Host header
|
||||
@@ -196,7 +196,7 @@ func TestAzureOIDCWithReverseProxy(t *testing.T) {
|
||||
// This test verifies Azure-specific provider detection and configuration
|
||||
// without making actual network calls
|
||||
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 1: Test session setup for Azure OAuth flow
|
||||
@@ -254,7 +254,7 @@ func TestAzureOIDCWithReverseProxy(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Mixed_HTTP_HTTPS_Requests", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate a scenario where some requests come via HTTPS proxy and some don't
|
||||
@@ -299,7 +299,7 @@ func TestAzureOIDCWithReverseProxy(t *testing.T) {
|
||||
// TestEnhanceSessionSecurity verifies the security enhancement function
|
||||
func TestEnhanceSessionSecurity(t *testing.T) {
|
||||
t.Run("Security_Enhancement_For_AJAX_Requests", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// AJAX request via HTTPS proxy
|
||||
@@ -327,7 +327,7 @@ func TestEnhanceSessionSecurity(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Security_Enhancement_Missing_User_Agent", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Request without User-Agent (potential bot/attack)
|
||||
|
||||
+1
-1
@@ -197,7 +197,7 @@ func TestCacheMemoryExhaustion(t *testing.T) {
|
||||
// TestSessionConcurrencyProtection tests session safety under concurrent access
|
||||
func TestSessionConcurrencyProtection(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sessionManager, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger)
|
||||
sessionManager, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
|
||||
@@ -461,7 +461,7 @@ func TestMissingClaims(t *testing.T) {
|
||||
// TestSessionFixationAttack tests the plugin's resistance to session fixation attacks
|
||||
func TestSessionFixationAttack(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger)
|
||||
sm, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
@@ -615,7 +615,7 @@ func TestSessionFixationAttack(t *testing.T) {
|
||||
// TestCSRFProtection tests CSRF protection in POST requests
|
||||
func TestCSRFProtection(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, logger)
|
||||
sm, err := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
|
||||
+106
-6
@@ -243,17 +243,20 @@ type SessionManager struct {
|
||||
store sessions.Store
|
||||
logger *Logger
|
||||
forceHTTPS bool
|
||||
cookieDomain string
|
||||
chunkManager *ChunkManager
|
||||
cleanupDone bool // Track if we've attempted cookie cleanup
|
||||
}
|
||||
|
||||
// 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
|
||||
// - cookieDomain: Explicit domain for cookies (e.g., ".example.com" for subdomain access)
|
||||
// - 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) {
|
||||
func NewSessionManager(encryptionKey string, forceHTTPS bool, cookieDomain string, 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)
|
||||
@@ -262,6 +265,7 @@ func NewSessionManager(encryptionKey string, forceHTTPS bool, logger *Logger) (*
|
||||
sm := &SessionManager{
|
||||
store: sessions.NewCookieStore([]byte(encryptionKey)),
|
||||
forceHTTPS: forceHTTPS,
|
||||
cookieDomain: cookieDomain,
|
||||
logger: logger,
|
||||
chunkManager: NewChunkManager(logger),
|
||||
}
|
||||
@@ -512,8 +516,13 @@ func (sm *SessionManager) EnhanceSessionSecurity(options *sessions.Options, r *h
|
||||
// Always enforce security best practices
|
||||
options.HttpOnly = true
|
||||
|
||||
// Set secure Domain attribute for production
|
||||
if options.Domain == "" && r != nil {
|
||||
// Set cookie domain from configuration if specified
|
||||
if sm.cookieDomain != "" {
|
||||
// Use the explicitly configured domain
|
||||
options.Domain = sm.cookieDomain
|
||||
sm.logger.Debugf("Using configured cookie domain: %s", sm.cookieDomain)
|
||||
} else if options.Domain == "" && r != nil {
|
||||
// Fall back to auto-detection only if no domain is configured
|
||||
// In reverse proxy setups, use the original host if available
|
||||
// This ensures cookies work correctly with the public domain
|
||||
host := r.Host
|
||||
@@ -529,10 +538,10 @@ func (sm *SessionManager) EnhanceSessionSecurity(options *sessions.Options, r *h
|
||||
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
|
||||
// Use the exact host (without leading dot) for host-only cookies
|
||||
// This prevents subdomain access but ensures consistency
|
||||
options.Domain = host
|
||||
sm.logger.Debugf("Auto-detected cookie domain: %s", host)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,10 +564,101 @@ func (sm *SessionManager) getSessionOptions(isSecure bool) *sessions.Options {
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(absoluteSessionTimeout.Seconds()),
|
||||
Path: "/",
|
||||
Domain: sm.cookieDomain, // Use configured domain if set
|
||||
}
|
||||
return baseOptions
|
||||
}
|
||||
|
||||
// CleanupOldCookies attempts to remove cookies with potentially mismatched domains.
|
||||
// Since browsers don't send domain info with cookies, this function tries to delete
|
||||
// cookies with various domain variations to ensure cleanup after configuration changes.
|
||||
func (sm *SessionManager) CleanupOldCookies(w http.ResponseWriter, r *http.Request) {
|
||||
// Get all cookies from the request
|
||||
cookies := r.Cookies()
|
||||
|
||||
// Get the current configured domain
|
||||
currentDomain := sm.cookieDomain
|
||||
host := r.Host
|
||||
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
|
||||
host = forwardedHost
|
||||
}
|
||||
// Remove port if present
|
||||
if colonIndex := strings.Index(host, ":"); colonIndex != -1 {
|
||||
host = host[:colonIndex]
|
||||
}
|
||||
|
||||
// Build list of domains to attempt deletion for
|
||||
// This ensures we clean up cookies from various possible domains
|
||||
var domainsToClean []string
|
||||
|
||||
// Add variations of the current host
|
||||
if host != "" && !strings.Contains(host, "localhost") && !strings.Contains(host, "127.0.0.1") {
|
||||
domainsToClean = append(domainsToClean,
|
||||
host, // exact host
|
||||
"."+host, // with leading dot
|
||||
)
|
||||
|
||||
// Add parent domain variations if host has subdomains
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) > 2 {
|
||||
// For "sub.example.com", also try "example.com" and ".example.com"
|
||||
parentDomain := strings.Join(parts[len(parts)-2:], ".")
|
||||
domainsToClean = append(domainsToClean,
|
||||
parentDomain,
|
||||
"."+parentDomain,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Track which cookies we're processing
|
||||
processedCookies := make(map[string]bool)
|
||||
|
||||
// Check each cookie
|
||||
for _, cookie := range cookies {
|
||||
// Only process our OIDC session cookies
|
||||
if strings.HasPrefix(cookie.Name, mainCookieName) ||
|
||||
strings.HasPrefix(cookie.Name, accessTokenCookie) ||
|
||||
strings.HasPrefix(cookie.Name, refreshTokenCookie) ||
|
||||
strings.HasPrefix(cookie.Name, "_oidc_raczylo_id") ||
|
||||
strings.HasPrefix(cookie.Name, "access_token_chunk_") ||
|
||||
strings.HasPrefix(cookie.Name, "refresh_token_chunk_") {
|
||||
|
||||
// Mark this cookie as seen
|
||||
processedCookies[cookie.Name] = true
|
||||
|
||||
// If we have a configured domain and this is the first request after config change,
|
||||
// attempt to delete the cookie with various domain variations to ensure cleanup
|
||||
if currentDomain != "" && !sm.cleanupDone {
|
||||
for _, domain := range domainsToClean {
|
||||
// Skip the current configured domain
|
||||
if domain == currentDomain || domain == "."+currentDomain || "."+domain == currentDomain {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create a deletion cookie for this domain variation
|
||||
deleteCookie := &http.Cookie{
|
||||
Name: cookie.Name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Domain: domain,
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: r.Header.Get("X-Forwarded-Proto") == "https" || r.TLS != nil || sm.forceHTTPS,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
http.SetCookie(w, deleteCookie)
|
||||
sm.logger.Debugf("Attempting to clean up cookie %s with domain %s", cookie.Name, domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark cleanup as done for this session manager instance
|
||||
if !sm.cleanupDone && len(processedCookies) > 0 {
|
||||
sm.cleanupDone = true
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func TestSessionCompatibilityAfterChanges(t *testing.T) {
|
||||
t.Run("Plain_HTTP_Without_Proxy_Headers", func(t *testing.T) {
|
||||
// Test that plain HTTP requests without proxy headers still work
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("GET", "http://localhost:8080/test", nil)
|
||||
@@ -67,7 +67,7 @@ func TestSessionCompatibilityAfterChanges(t *testing.T) {
|
||||
|
||||
t.Run("HTTPS_With_TLS_Field", func(t *testing.T) {
|
||||
// Test direct HTTPS connection (not proxied)
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("GET", "https://example.com/test", nil)
|
||||
@@ -101,7 +101,7 @@ func TestSessionCompatibilityAfterChanges(t *testing.T) {
|
||||
|
||||
t.Run("ForceHTTPS_Setting", func(t *testing.T) {
|
||||
// Test forceHTTPS setting works regardless of request
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", true, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", true, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Plain HTTP request
|
||||
@@ -132,7 +132,7 @@ func TestSessionCompatibilityAfterChanges(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("AJAX_Request_Gets_Strict_SameSite", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// AJAX request
|
||||
@@ -165,7 +165,7 @@ func TestSessionCompatibilityAfterChanges(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Missing_UserAgent_Gets_Reduced_Timeout", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, "", NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Request without User-Agent (suspicious)
|
||||
|
||||
+11
-11
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
func TestSessionPoolMemoryLeak(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func TestSessionPoolMemoryLeak(t *testing.T) {
|
||||
|
||||
// Test 3: Error path in GetSession
|
||||
// Modify the session store to force an error - use a different encryption key
|
||||
badSM, _ := NewSessionManager("different0123456789abcdef0123456789abcdef0123456789", false, logger)
|
||||
badSM, _ := NewSessionManager("different0123456789abcdef0123456789abcdef0123456789", false, "", logger)
|
||||
|
||||
// Get session using mismatched manager/request to force error
|
||||
_, err = badSM.GetSession(req)
|
||||
@@ -68,7 +68,7 @@ func TestSessionPoolMemoryLeak(t *testing.T) {
|
||||
|
||||
func TestSessionErrorHandling(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func TestSessionErrorHandling(t *testing.T) {
|
||||
|
||||
func TestSessionClearAlwaysReturnsToPool(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func getPooledObjects(sm *SessionManager) int {
|
||||
// returned to the pool in various scenarios including normal usage and error paths
|
||||
func TestSessionObjectTracking(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
@@ -344,7 +344,7 @@ func TestTokenCompressionCorruptionDetection(t *testing.T) {
|
||||
// TestTokenChunkingIntegrity tests that large tokens are properly chunked and reassembled
|
||||
func TestTokenChunkingIntegrity(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
@@ -419,7 +419,7 @@ func TestTokenChunkingIntegrity(t *testing.T) {
|
||||
// TestTokenChunkingCorruptionResistance tests handling of corrupted chunks
|
||||
func TestTokenChunkingCorruptionResistance(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
@@ -525,7 +525,7 @@ func TestTokenChunkingCorruptionResistance(t *testing.T) {
|
||||
// TestTokenSizeLimits tests that token size limits are enforced
|
||||
func TestTokenSizeLimits(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
@@ -592,7 +592,7 @@ func TestTokenSizeLimits(t *testing.T) {
|
||||
// TestConcurrentTokenOperations tests thread safety of token operations
|
||||
func TestConcurrentTokenOperations(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
@@ -646,7 +646,7 @@ func TestConcurrentTokenOperations(t *testing.T) {
|
||||
// TestSessionValidationAndCleanup tests session validation and orphan cleanup
|
||||
func TestSessionValidationAndCleanup(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
@@ -694,7 +694,7 @@ func TestSessionValidationAndCleanup(t *testing.T) {
|
||||
// TestLargeIDTokenChunking tests that large ID tokens are properly chunked across multiple cookies
|
||||
func TestLargeIDTokenChunking(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ type Config struct {
|
||||
AllowedUsers []string `json:"allowedUsers"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Headers []TemplatedHeader `json:"headers"`
|
||||
CookieDomain string `json:"cookieDomain"`
|
||||
RateLimit int `json:"rateLimit"`
|
||||
RefreshGracePeriodSeconds int `json:"refreshGracePeriodSeconds"`
|
||||
ForceHTTPS bool `json:"forceHTTPS"`
|
||||
|
||||
@@ -85,7 +85,7 @@ func setupTestOIDCMiddleware(t *testing.T, config *Config) (*TraefikOidc, *httpt
|
||||
logInfo: log.New(&testWriter{t}, "INFO: ", 0),
|
||||
logDebug: log.New(&testWriter{t}, "DEBUG: ", 0),
|
||||
}
|
||||
sessionManager, _ := NewSessionManager(config.SessionEncryptionKey, false, logger)
|
||||
sessionManager, _ := NewSessionManager(config.SessionEncryptionKey, false, "", logger)
|
||||
|
||||
// Create next handler
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -217,7 +217,7 @@ func createMockJWT(t *testing.T, sub, email string) string {
|
||||
func createTestSession() *SessionData {
|
||||
// Create a minimal session manager for testing
|
||||
logger := newNoOpLogger()
|
||||
sessionManager, _ := NewSessionManager("test-encryption-key-32-characters", false, logger)
|
||||
sessionManager, _ := NewSessionManager("test-encryption-key-32-characters", false, "", logger)
|
||||
|
||||
// Create a test request
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// Token verified successfully multiple times, then fails with "signature verification failed"
|
||||
func TestTokenCorruptionScenario(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
@@ -180,7 +180,7 @@ func TestCompressionIntegrityFailure(t *testing.T) {
|
||||
// TestChunkReassemblyEdgeCases tests edge cases in chunk reassembly that could cause corruption
|
||||
func TestChunkReassemblyEdgeCases(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
@@ -296,7 +296,7 @@ func TestChunkReassemblyEdgeCases(t *testing.T) {
|
||||
// TestRaceConditionProtection tests that concurrent access doesn't cause corruption
|
||||
func TestRaceConditionProtection(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
@@ -432,7 +432,7 @@ func TestMemoryExhaustionProtection(t *testing.T) {
|
||||
// TestBackwardCompatibility ensures that sessions created before the fixes still work
|
||||
func TestBackwardCompatibility(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ func TestSessionIDTokenAccessToken(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
|
||||
// Create a session manager
|
||||
sessionManager, err := NewSessionManager("test-session-encryption-key-at-least-32-bytes", false, logger)
|
||||
sessionManager, err := NewSessionManager("test-session-encryption-key-at-least-32-bytes", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
@@ -318,7 +318,7 @@ func TestSessionIDTokenAccessToken(t *testing.T) {
|
||||
// TestTokenCorruptionIntegrationFlows tests the complete token handling flow with corruption scenarios
|
||||
func TestTokenCorruptionIntegrationFlows(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
@@ -442,7 +442,7 @@ func TestTokenCorruptionIntegrationFlows(t *testing.T) {
|
||||
// TestSessionPersistenceWithCorruption tests that session corruption is handled across requests
|
||||
func TestSessionPersistenceWithCorruption(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
@@ -506,7 +506,7 @@ func TestSessionPersistenceWithCorruption(t *testing.T) {
|
||||
// TestConcurrentTokenOperationsWithCorruption tests concurrent access with intentional corruption
|
||||
func TestConcurrentTokenOperationsWithCorruption(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
@@ -592,7 +592,7 @@ func TestConcurrentTokenOperationsWithCorruption(t *testing.T) {
|
||||
// TestTokenValidationEdgeCases tests edge cases in token validation
|
||||
func TestTokenValidationEdgeCases(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, "", logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user