mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-14 04:07:46 +00:00
Issue #53: Fix CSRF token handling in reverse proxy
1. ✅ HTTPS Detection Fixed (session.go:723) - Now uses X-Forwarded-Proto header instead of r.URL.Scheme - Properly detects HTTPS in reverse proxy environments 2. ✅ SameSite Cookie Attribute Fixed - Removed automatic SameSiteStrictMode for HTTPS (would break OAuth) - Keeps SameSiteLaxMode to allow OAuth callbacks from external domains - Only uses Strict for AJAX requests which don't involve OAuth redirects 3. ✅ Cookie Domain Handling Fixed - Now respects X-Forwarded-Host header for cookie domain - Ensures cookies are set for the public domain, not internal proxy domain 4. ✅ EnhanceSessionSecurity Properly Integrated - Function is now actually called during session save - Applies security enhancements without breaking OAuth flow Why Issue #53 Failed Before: 1. Cookies were not marked Secure in HTTPS environments (browser wouldn't send them back) 2. If they had been Secure with SameSite=Strict, Azure callbacks would still fail 3. Cookie domain might have been wrong (internal vs public domain) Why It Works Now: 1. Cookies are properly marked Secure for HTTPS 2. Uses SameSite=Lax to allow OAuth provider callbacks 3. Cookie domain uses public domain from X-Forwarded-Host 4. CSRF token persists through the entire OAuth flow
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 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"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate request from reverse proxy (Traefik/nginx)
|
||||
// The reverse proxy terminates SSL and forwards HTTP internally
|
||||
req := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
req.Header.Set("X-Forwarded-Host", "example.com")
|
||||
|
||||
session, err := sessionManager.GetSession(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set critical session data
|
||||
session.SetCSRF("important-csrf-token")
|
||||
session.SetNonce("test-nonce")
|
||||
|
||||
// Save session
|
||||
rec := httptest.NewRecorder()
|
||||
err = session.Save(req, rec)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify cookies have Secure flag when X-Forwarded-Proto is https
|
||||
cookies := rec.Result().Cookies()
|
||||
var mainCookie *http.Cookie
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == "_oidc_raczylo_m" {
|
||||
mainCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, mainCookie, "Main session cookie should be set")
|
||||
assert.True(t, mainCookie.Secure, "Cookie should have Secure flag when X-Forwarded-Proto is https")
|
||||
assert.Equal(t, http.SameSiteLaxMode, mainCookie.SameSite, "Should use Lax SameSite for OAuth compatibility")
|
||||
})
|
||||
|
||||
t.Run("HTTPS_Detection_Without_Headers", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Request without reverse proxy headers (direct HTTP)
|
||||
req := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
|
||||
session, err := sessionManager.GetSession(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
session.SetCSRF("test-csrf")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
err = session.Save(req, rec)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify cookies don't have Secure flag for plain HTTP
|
||||
cookies := rec.Result().Cookies()
|
||||
var mainCookie *http.Cookie
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == "_oidc_raczylo_m" {
|
||||
mainCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, mainCookie)
|
||||
assert.False(t, mainCookie.Secure, "Cookie should not have Secure flag for HTTP")
|
||||
assert.Equal(t, http.SameSiteLaxMode, mainCookie.SameSite, "Should use Lax SameSite in HTTP")
|
||||
})
|
||||
|
||||
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"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Even without headers, forceHTTPS should make cookies secure
|
||||
req := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
|
||||
session, err := sessionManager.GetSession(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
session.SetCSRF("forced-secure-csrf")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
err = session.Save(req, rec)
|
||||
require.NoError(t, err)
|
||||
|
||||
cookies := rec.Result().Cookies()
|
||||
var mainCookie *http.Cookie
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == "_oidc_raczylo_m" {
|
||||
mainCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, mainCookie)
|
||||
assert.True(t, mainCookie.Secure, "Cookie should have Secure flag with forceHTTPS")
|
||||
})
|
||||
}
|
||||
|
||||
// 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"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 1: Initial request to protected resource (HTTPS via proxy)
|
||||
req1 := httptest.NewRequest("GET", "http://example.com/protected", nil)
|
||||
req1.Header.Set("X-Forwarded-Proto", "https")
|
||||
req1.Header.Set("X-Forwarded-Host", "example.com")
|
||||
|
||||
session1, err := sessionManager.GetSession(req1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set CSRF and other auth flow data
|
||||
csrfToken := "proxy-csrf-token-12345"
|
||||
session1.SetCSRF(csrfToken)
|
||||
session1.SetNonce("proxy-nonce")
|
||||
session1.SetIncomingPath("/protected")
|
||||
|
||||
// Save session (should set Secure cookie)
|
||||
rec1 := httptest.NewRecorder()
|
||||
err = session1.Save(req1, rec1)
|
||||
require.NoError(t, err)
|
||||
|
||||
cookies := rec1.Result().Cookies()
|
||||
|
||||
// Step 2: Simulate OAuth callback (also HTTPS via proxy)
|
||||
req2 := httptest.NewRequest("GET", "http://example.com/oidc/callback?code=auth-code&state="+csrfToken, nil)
|
||||
req2.Header.Set("X-Forwarded-Proto", "https")
|
||||
req2.Header.Set("X-Forwarded-Host", "example.com")
|
||||
|
||||
// Add cookies from step 1
|
||||
for _, cookie := range cookies {
|
||||
req2.AddCookie(cookie)
|
||||
}
|
||||
|
||||
// Get session in callback
|
||||
session2, err := sessionManager.GetSession(req2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// CSRF token should persist
|
||||
retrievedCSRF := session2.GetCSRF()
|
||||
assert.Equal(t, csrfToken, retrievedCSRF, "CSRF token must persist through OAuth flow in reverse proxy")
|
||||
assert.Equal(t, "proxy-nonce", session2.GetNonce(), "Nonce should also persist")
|
||||
assert.Equal(t, "/protected", session2.GetIncomingPath(), "Incoming path should persist")
|
||||
})
|
||||
|
||||
t.Run("Session_Cookie_Domain_With_Proxy_Headers", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test with X-Forwarded-Host header
|
||||
req := httptest.NewRequest("GET", "http://internal.local/test", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
req.Header.Set("X-Forwarded-Host", "public.example.com")
|
||||
req.Host = "internal.local" // Internal host
|
||||
|
||||
session, err := sessionManager.GetSession(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
session.SetCSRF("domain-test-csrf")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
err = session.Save(req, rec)
|
||||
require.NoError(t, err)
|
||||
|
||||
cookies := rec.Result().Cookies()
|
||||
var mainCookie *http.Cookie
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == "_oidc_raczylo_m" {
|
||||
mainCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, mainCookie)
|
||||
|
||||
// Domain should be set based on X-Forwarded-Host when present
|
||||
// This ensures cookies work correctly with the public domain
|
||||
assert.Equal(t, "public.example.com", mainCookie.Domain, "Cookie domain should use forwarded host")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAzureOIDCWithReverseProxy simulates Azure OIDC flow behind a reverse proxy
|
||||
func TestAzureOIDCWithReverseProxy(t *testing.T) {
|
||||
t.Run("Azure_Provider_Detection_And_Configuration", func(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"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 1: Test session setup for Azure OAuth flow
|
||||
req := httptest.NewRequest("GET", "http://internal/protected", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
req.Header.Set("X-Forwarded-Host", "app.example.com")
|
||||
|
||||
session, err := sessionManager.GetSession(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate OAuth flow initialization
|
||||
csrfToken := "azure-csrf-token"
|
||||
nonce := "azure-nonce"
|
||||
session.SetCSRF(csrfToken)
|
||||
session.SetNonce(nonce)
|
||||
session.SetIncomingPath("/protected")
|
||||
session.MarkDirty()
|
||||
|
||||
// Save session with proper HTTPS detection
|
||||
rec := httptest.NewRecorder()
|
||||
err = session.Save(req, rec)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify cookies have correct security attributes for Azure
|
||||
cookies := rec.Result().Cookies()
|
||||
var mainCookie *http.Cookie
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == "_oidc_raczylo_m" {
|
||||
mainCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, mainCookie, "Main session cookie must be set")
|
||||
assert.True(t, mainCookie.Secure, "Cookie must be secure for HTTPS reverse proxy")
|
||||
assert.Equal(t, http.SameSiteLaxMode, mainCookie.SameSite, "Should use Lax SameSite for OAuth compatibility")
|
||||
|
||||
// Step 2: Simulate callback and verify session persistence
|
||||
callbackReq := httptest.NewRequest("GET", "http://internal/oidc/callback?code=azure-code&state="+csrfToken, nil)
|
||||
callbackReq.Header.Set("X-Forwarded-Proto", "https")
|
||||
callbackReq.Header.Set("X-Forwarded-Host", "app.example.com")
|
||||
|
||||
// Add cookies from initial request
|
||||
for _, cookie := range cookies {
|
||||
callbackReq.AddCookie(cookie)
|
||||
}
|
||||
|
||||
// Get session in callback
|
||||
callbackSession, err := sessionManager.GetSession(callbackReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify session data persisted correctly
|
||||
assert.Equal(t, csrfToken, callbackSession.GetCSRF(), "CSRF token must persist in Azure flow")
|
||||
assert.Equal(t, nonce, callbackSession.GetNonce(), "Nonce must persist")
|
||||
assert.Equal(t, "/protected", callbackSession.GetIncomingPath(), "Original path must persist")
|
||||
})
|
||||
|
||||
t.Run("Mixed_HTTP_HTTPS_Requests", func(t *testing.T) {
|
||||
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
|
||||
// This can happen in development or misconfigured environments
|
||||
|
||||
// Request 1: HTTPS via proxy
|
||||
req1 := httptest.NewRequest("GET", "http://localhost:8080/test", nil)
|
||||
req1.Header.Set("X-Forwarded-Proto", "https")
|
||||
|
||||
session1, err := sessionManager.GetSession(req1)
|
||||
require.NoError(t, err)
|
||||
session1.SetCSRF("mixed-csrf")
|
||||
|
||||
rec1 := httptest.NewRecorder()
|
||||
err = session1.Save(req1, rec1)
|
||||
require.NoError(t, err)
|
||||
|
||||
cookies1 := rec1.Result().Cookies()
|
||||
|
||||
// Request 2: Direct HTTP (no proxy headers)
|
||||
req2 := httptest.NewRequest("GET", "http://localhost:8080/test", nil)
|
||||
// No X-Forwarded-Proto header
|
||||
|
||||
// Try to use cookies from HTTPS request
|
||||
for _, cookie := range cookies1 {
|
||||
// Remove Secure flag to simulate browser behavior
|
||||
// (browser wouldn't send secure cookie over HTTP)
|
||||
if !cookie.Secure {
|
||||
req2.AddCookie(cookie)
|
||||
}
|
||||
}
|
||||
|
||||
session2, err := sessionManager.GetSession(req2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Session should be empty because secure cookies weren't sent
|
||||
csrf2 := session2.GetCSRF()
|
||||
assert.Empty(t, csrf2, "CSRF should be empty when secure cookies can't be sent over HTTP")
|
||||
})
|
||||
}
|
||||
|
||||
// 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"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// AJAX request via HTTPS proxy
|
||||
req := httptest.NewRequest("GET", "http://internal/api/data", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
|
||||
session, err := sessionManager.GetSession(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
session.SetCSRF("ajax-csrf")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
err = session.Save(req, rec)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that AJAX requests get strict same-site
|
||||
cookies := rec.Result().Cookies()
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == "_oidc_raczylo_m" {
|
||||
assert.Equal(t, http.SameSiteStrictMode, cookie.SameSite, "AJAX requests should use Strict SameSite")
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Security_Enhancement_Missing_User_Agent", func(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager("test-encryption-key-32-characters", false, NewLogger("debug"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Request without User-Agent (potential bot/attack)
|
||||
req := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
// No User-Agent header
|
||||
|
||||
session, err := sessionManager.GetSession(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
session.SetCSRF("no-ua-csrf")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
err = session.Save(req, rec)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify reduced session timeout for suspicious requests
|
||||
cookies := rec.Result().Cookies()
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == "_oidc_raczylo_m" {
|
||||
// Should have reduced MaxAge (half of normal)
|
||||
assert.Less(t, cookie.MaxAge, int(absoluteSessionTimeout.Seconds()), "Suspicious requests should have reduced timeout")
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user