Files
traefikoidc/security_monitoring_test.go
lukaszraczylo 1b49e133da Complete rebuild of the plugin
* Fix bug affecting Azure OIDC authentication ( and most likely others )

* Fixes issue #51

* Ensure that appended roles are unique. Update the documentation.

* Improvements targetting possible memory usage spikes.

* Additional fixes and cleanup

* Refactoring code to fix the issues identified by the users.

* Modernize run

* Fieldalignment

* Multiple changes to improve performance and reduce complexity.
- Optimise the errors and recovery.
- Deduplicate code in metadata cache.
- Remove unused performance monitoring code.
- Simplify session management and settings handling.

* Fix claims issue.

* Add ability to overwrite the default scopes in the settings file

* Well.. that escalated quickly.

Completely forgot that Traefik uses outdated Yaegi and requires compatibility with 1.20 ( pre-generic Go code ).

* Bugfix #51: Ensures that user provided scopes overrides work.

* fixup! Bugfix #51: Ensures that user provided scopes overrides work.

* fixup! fixup! Bugfix #51: Ensures that user provided scopes overrides work.

* Abstract the provider logic into a separate package.

* Additional micro fixes and cleanups.

* Simplify all the things.

* fixup! Simplify all the things.

* fixup! fixup! Simplify all the things.

* fixup! fixup! fixup! Simplify all the things.

* fixup! fixup! fixup! fixup! Simplify all the things.

* ...

* Cleanup tests.

* fixup! Cleanup tests.

* fixup! fixup! fixup! Cleanup tests.

* fixup! fixup! fixup! fixup! Cleanup tests.

* fixup! fixup! fixup! fixup! fixup! Cleanup tests.

* Issue #53: Fix CSRF token handling in reverse proxy

1.  HTTPS Detection Fixed (session.go:723)
- Now uses X-Forwarded-Proto header instead of r.URL.Scheme
- Properly detects HTTPS in reverse proxy environments
2.  SameSite Cookie Attribute Fixed
- Removed automatic SameSiteStrictMode for HTTPS (would break OAuth)
- Keeps SameSiteLaxMode to allow OAuth callbacks from external domains
- Only uses Strict for AJAX requests which don't involve OAuth redirects
3.  Cookie Domain Handling Fixed
- Now respects X-Forwarded-Host header for cookie domain
- Ensures cookies are set for the public domain, not internal proxy domain
4.  EnhanceSessionSecurity Properly Integrated
- Function is now actually called during session save
- Applies security enhancements without breaking OAuth flow

Why Issue #53 Failed Before:

1. Cookies were not marked Secure in HTTPS environments (browser wouldn't send them back)
2. If they had been Secure with SameSite=Strict, Azure callbacks would still fail
3. Cookie domain might have been wrong (internal vs public domain)

Why It Works Now:

1. Cookies are properly marked Secure for HTTPS
2. Uses SameSite=Lax to allow OAuth provider callbacks
3. Cookie domain uses public domain from X-Forwarded-Host
4. CSRF token persists through the entire OAuth flow

* Next set of enhancements together with memory usage improvements.

* Memory leak fixes and optimisations.

* CSRF and Cookie Domain fixes

* fixup! CSRF and Cookie Domain fixes

* Metadata cache leak fix + profiling

* fixup! Metadata cache leak fix + profiling

* Memory leaks hunting, part 1337.

* Further pursue of perfection.

* fixup! Further pursue of perfection.

* fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.

* Clear race conditions

* fixup! Clear race conditions

* Weekend fun with memory leaks

* Splitting code into multiple files with reasonable testing coverage.

```
ok      github.com/lukaszraczylo/traefikoidc    117.017s        coverage: 72.6% of statements
ok      github.com/lukaszraczylo/traefikoidc/auth       0.505s  coverage: 87.1% of statements
ok      github.com/lukaszraczylo/traefikoidc/circuit_breaker    0.283s  coverage: 99.0% of statements
        github.com/lukaszraczylo/traefikoidc/config             coverage: 0.0% of statements
ok      github.com/lukaszraczylo/traefikoidc/handlers   0.349s  coverage: 98.2% of statements
ok      github.com/lukaszraczylo/traefikoidc/internal/providers (cached)        coverage: 94.3% of statements
ok      github.com/lukaszraczylo/traefikoidc/middleware 0.808s  coverage: 78.0% of statements
ok      github.com/lukaszraczylo/traefikoidc/recovery   0.653s  coverage: 100.0% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/chunking   (cached)        coverage: 87.8% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/core       (cached)        coverage: 85.6% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/crypto     (cached)        coverage: 81.8% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/storage    (cached)        coverage: 93.5% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/validators (cached)        coverage: 98.8% of statements
````

* fixup! Splitting code into multiple files with reasonable testing coverage.

* fixup! fixup! Splitting code into multiple files with reasonable testing coverage.

* Weekend fun with further optimisations.

* fixup! Weekend fun with further optimisations.

* fixup! fixup! Weekend fun with further optimisations.

* fixup! fixup! fixup! Weekend fun with further optimisations.

* fixup! fixup! fixup! fixup! Weekend fun with further optimisations.

* fixup! fixup! fixup! fixup! fixup! Weekend fun with further optimisations.

* Pre-release cleanup.

* Enhance test coverage.

* fixup! Enhance test coverage.

* fixup! fixup! Enhance test coverage.

* fixup! fixup! fixup! Enhance test coverage.
2025-09-18 11:01:30 +01:00

286 lines
7.7 KiB
Go

package traefikoidc
import (
"net/http/httptest"
"strconv"
"testing"
"time"
)
func TestSecurityMonitor(t *testing.T) {
config := DefaultSecurityMonitorConfig()
config.MaxFailuresPerIP = 3
config.BlockDurationMinutes = 1 // 1 minute for testing
config.CleanupIntervalMinutes = 1
logger := NewLogger("debug")
monitor := NewSecurityMonitor(config, logger)
defer func() {
// Allow cleanup goroutine to finish
time.Sleep(150 * time.Millisecond)
}()
t.Run("Record authentication failure", func(t *testing.T) {
monitor.RecordAuthenticationFailure("192.168.1.1", "test-agent", "/login", "invalid credentials", nil)
// Should not be blocked after first failure
if monitor.IsIPBlocked("192.168.1.1") {
t.Error("IP should not be blocked after first failure")
}
})
t.Run("IP blocked after max failures", func(t *testing.T) {
// Record multiple failures
for i := 0; i < config.MaxFailuresPerIP; i++ {
monitor.RecordAuthenticationFailure("192.168.1.2", "test-agent", "/login", "invalid credentials", nil)
}
// Should be blocked now
if !monitor.IsIPBlocked("192.168.1.2") {
t.Error("IP should be blocked after max failures")
}
})
t.Run("Token validation failure", func(t *testing.T) {
// Just verify the method doesn't panic
monitor.RecordTokenValidationFailure("192.168.1.3", "test-agent", "/api", "invalid token", "abc123")
})
t.Run("Rate limit hit", func(t *testing.T) {
// Just verify the method doesn't panic
monitor.RecordRateLimitHit("192.168.1.4", "test-agent", "/api")
})
t.Run("Suspicious activity", func(t *testing.T) {
details := map[string]interface{}{"pattern": "unusual"}
// Just verify the method doesn't panic
monitor.RecordSuspiciousActivity("192.168.1.5", "test-agent", "/admin", "unusual pattern", "high frequency requests", details)
})
}
func TestSuspiciousPatternDetector(t *testing.T) {
detector := NewSuspiciousPatternDetector()
t.Run("Add events and detect patterns", func(t *testing.T) {
// Add multiple events from same IP
for i := 0; i < 10; i++ {
event := SecurityEvent{
Type: "authentication_failure",
ClientIP: "192.168.1.100",
Timestamp: time.Now(),
}
detector.AddEvent(event)
}
patterns := detector.DetectSuspiciousPatterns()
found := false
for _, p := range patterns {
if p == "rapid_failures_from_ip_192.168.1.100" {
found = true
break
}
}
if !found {
t.Error("Expected to detect rapid failure pattern")
}
})
t.Run("Detect distributed attack pattern", func(t *testing.T) {
// Add failures from many different IPs
for i := 0; i < 25; i++ {
event := SecurityEvent{
Type: "authentication_failure",
ClientIP: "192.168.1." + strconv.Itoa(100+i),
Timestamp: time.Now(),
}
detector.AddEvent(event)
}
patterns := detector.DetectSuspiciousPatterns()
found := false
for _, p := range patterns {
if p == "distributed_attack_pattern" {
found = true
break
}
}
if !found {
t.Error("Expected to detect distributed attack pattern")
}
})
}
func TestExtractClientIP(t *testing.T) {
tests := []struct {
name string
remoteAddr string
headers map[string]string
expectedIP string
}{
{
name: "Direct connection",
remoteAddr: "192.168.1.1:12345",
expectedIP: "192.168.1.1",
},
{
name: "X-Forwarded-For header",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{"X-Forwarded-For": "203.0.113.1, 10.0.0.1"},
expectedIP: "203.0.113.1",
},
{
name: "X-Real-IP header",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{"X-Real-IP": "203.0.113.2"},
expectedIP: "203.0.113.2",
},
{
name: "Multiple headers - X-Real-IP takes precedence",
remoteAddr: "10.0.0.1:12345",
headers: map[string]string{
"X-Forwarded-For": "203.0.113.1",
"X-Real-IP": "203.0.113.2",
},
expectedIP: "203.0.113.2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = tt.remoteAddr
for key, value := range tt.headers {
req.Header.Set(key, value)
}
ip := ExtractClientIP(req)
if ip != tt.expectedIP {
t.Errorf("Expected IP %s, got %s", tt.expectedIP, ip)
}
})
}
}
func TestSecurityEventHandlers(t *testing.T) {
t.Run("Logging security event handler", func(t *testing.T) {
logger := NewLogger("debug")
handler := NewLoggingSecurityEventHandler(logger)
event := SecurityEvent{
Type: "authentication_failure",
ClientIP: "192.168.1.1",
Timestamp: time.Now(),
Message: "Test failure",
Severity: "medium",
}
// Should not panic
handler.HandleSecurityEvent(event)
})
// Metrics security event handler test removed as part of metrics cleanup
}
func TestSecurityMonitorEventHandlers(t *testing.T) {
config := DefaultSecurityMonitorConfig()
logger := NewLogger("debug")
monitor := NewSecurityMonitor(config, logger)
// Add event handler with proper synchronization
handlerCalled := make(chan bool, 1)
handler := &testSecurityEventHandler{
callback: func(event SecurityEvent) {
select {
case handlerCalled <- true:
default:
// Channel already has a value, don't block
}
},
}
monitor.AddEventHandler(handler)
monitor.RecordAuthenticationFailure("192.168.1.1", "test-agent", "/login", "test failure", nil)
// Wait for event handler to be called with timeout
select {
case <-handlerCalled:
// Success - handler was called
case <-time.After(100 * time.Millisecond):
t.Error("Expected event handler to be called within timeout")
}
}
// Test helper for security event handler
type testSecurityEventHandler struct {
callback func(SecurityEvent)
}
func (h *testSecurityEventHandler) HandleSecurityEvent(event SecurityEvent) {
h.callback(event)
}
func TestDefaultSecurityMonitorConfig(t *testing.T) {
config := DefaultSecurityMonitorConfig()
if config.MaxFailuresPerIP <= 0 {
t.Error("Expected positive MaxFailuresPerIP")
}
if config.BlockDurationMinutes <= 0 {
t.Error("Expected positive BlockDurationMinutes")
}
if config.CleanupIntervalMinutes <= 0 {
t.Error("Expected positive CleanupIntervalMinutes")
}
if config.FailureWindowMinutes <= 0 {
t.Error("Expected positive FailureWindowMinutes")
}
}
func TestSecurityMonitorCleanup(t *testing.T) {
config := DefaultSecurityMonitorConfig()
config.CleanupIntervalMinutes = 1
config.BlockDurationMinutes = 1
config.RetentionHours = 1
logger := NewLogger("debug")
monitor := NewSecurityMonitor(config, logger)
// Block an IP
for i := 0; i < config.MaxFailuresPerIP; i++ {
monitor.RecordAuthenticationFailure("192.168.1.99", "test-agent", "/login", "test", nil)
}
// Verify it's blocked
if !monitor.IsIPBlocked("192.168.1.99") {
t.Error("IP should be blocked")
}
// Wait a bit and check if it gets unblocked automatically
time.Sleep(100 * time.Millisecond)
// The IP should still be blocked since we haven't waited long enough
if !monitor.IsIPBlocked("192.168.1.99") {
t.Error("IP should still be blocked")
}
}
func TestSecurityEventTypes(t *testing.T) {
config := DefaultSecurityMonitorConfig()
logger := NewLogger("debug")
monitor := NewSecurityMonitor(config, logger)
// Test different event types - just verify they don't panic
monitor.RecordAuthenticationFailure("192.168.1.200", "test-agent", "/login", "invalid password", nil)
monitor.RecordTokenValidationFailure("192.168.1.200", "test-agent", "/api", "expired token", "abc123")
monitor.RecordRateLimitHit("192.168.1.200", "test-agent", "/api")
details := map[string]interface{}{"pattern": "test"}
monitor.RecordSuspiciousActivity("192.168.1.200", "test-agent", "/admin", "unusual pattern", "multiple failed logins", details)
// Just verify GetSecurityMetrics doesn't panic
_ = monitor.GetSecurityMetrics()
}