Files
traefikoidc/reverse_proxy_test.go
T
lukaszraczylo 1fd762848a 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
2025-08-14 12:10:51 +01:00

358 lines
12 KiB
Go

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
}
}
})
}