package security_test import ( "fmt" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/juanatsap/cv-site/internal/handlers" "github.com/juanatsap/cv-site/internal/middleware" "github.com/juanatsap/cv-site/internal/services" "github.com/juanatsap/cv-site/internal/templates" ) // mockEmailSender is a mock implementation of handlers.EmailSender for testing type mockEmailSender struct{} func (m *mockEmailSender) SendContactForm(data *services.ContactFormData) error { // Validate like the real service would if err := data.Validate(); err != nil { return fmt.Errorf("validation failed: %w", err) } // Always succeed - don't actually send return nil } // setupTestServer creates a test server with the contact handler and security middleware func setupTestServer(t *testing.T) http.Handler { t.Helper() // Create a minimal template manager for testing tmpl := &templates.Manager{} // Create mock email service that doesn't actually send emailService := &mockEmailSender{} // Create the contact handler contactHandler := handlers.NewContactHandler(tmpl, emailService) // Apply the same middleware chain as production: // BrowserOnly → RateLimiter → Handler rateLimiter := middleware.NewContactRateLimiter() protectedHandler := middleware.BrowserOnly( rateLimiter.Middleware( http.HandlerFunc(contactHandler.Submit), ), ) return protectedHandler } // createValidContactRequest creates a valid contact form request for testing func createValidContactRequest() *http.Request { // Calculate submit time (5 seconds ago to pass timing check) submitTime := time.Now().Add(-5 * time.Second) submitTimeMs := submitTime.UnixMilli() formData := url.Values{ "email": {"test@example.com"}, "name": {"John Doe"}, "company": {"Test Corp"}, "subject": {"Test Subject"}, "message": {"This is a test message with more than 10 characters."}, "website": {""}, // Honeypot - must be empty "submit_time": {fmt.Sprintf("%d", submitTimeMs)}, // Timing check } req := httptest.NewRequest(http.MethodPost, "/api/contact", strings.NewReader(formData.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // Add browser headers req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36") req.Header.Set("Referer", "http://localhost:8080/") req.Header.Set("HX-Request", "true") return req } // TestBrowserOnlyMiddleware_BlocksCurl tests that curl requests are blocked func TestBrowserOnlyMiddleware_BlocksCurl(t *testing.T) { handler := setupTestServer(t) tests := []struct { name string userAgent string wantCode int }{ { name: "block curl", userAgent: "curl/7.68.0", wantCode: http.StatusForbidden, }, { name: "block wget", userAgent: "Wget/1.20.3", wantCode: http.StatusForbidden, }, { name: "block postman", userAgent: "PostmanRuntime/7.26.8", wantCode: http.StatusForbidden, }, { name: "block python requests", userAgent: "python-requests/2.25.1", wantCode: http.StatusForbidden, }, { name: "block insomnia", userAgent: "insomnia/2021.1.0", wantCode: http.StatusForbidden, }, { name: "block httpie", userAgent: "HTTPie/2.4.0", wantCode: http.StatusForbidden, }, { name: "block go http client", userAgent: "Go-http-client/1.1", wantCode: http.StatusForbidden, }, { name: "block empty user agent", userAgent: "", wantCode: http.StatusForbidden, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := createValidContactRequest() req.Header.Set("User-Agent", tt.userAgent) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != tt.wantCode { t.Errorf("expected status %d, got %d", tt.wantCode, rr.Code) } if rr.Code == http.StatusForbidden { if !strings.Contains(rr.Body.String(), "Forbidden") { t.Errorf("expected Forbidden message, got: %s", rr.Body.String()) } } }) } } // TestBrowserOnlyMiddleware_RequiresRefererOrOrigin tests that requests without Referer/Origin are blocked func TestBrowserOnlyMiddleware_RequiresRefererOrOrigin(t *testing.T) { handler := setupTestServer(t) tests := []struct { name string referer string origin string wantCode int }{ { name: "no referer no origin - blocked", referer: "", origin: "", wantCode: http.StatusForbidden, }, { name: "with referer - allowed", referer: "http://localhost:8080/", origin: "", wantCode: http.StatusOK, }, { name: "with origin - allowed", referer: "", origin: "http://localhost:8080", wantCode: http.StatusOK, }, { name: "with both - allowed", referer: "http://localhost:8080/", origin: "http://localhost:8080", wantCode: http.StatusOK, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := createValidContactRequest() // Clear default headers req.Header.Del("Referer") req.Header.Del("Origin") // Set test headers if tt.referer != "" { req.Header.Set("Referer", tt.referer) } if tt.origin != "" { req.Header.Set("Origin", tt.origin) } rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) // Note: Tests that pass security may fail at email sending (expected in test environment) // We only care about security middleware blocking (403) vs allowing through if tt.wantCode == http.StatusForbidden { if rr.Code != tt.wantCode { t.Errorf("expected status %d, got %d", tt.wantCode, rr.Code) } } else { // If not expecting forbidden, just verify it's not forbidden // (may be 200 or 500 depending on email service availability) if rr.Code == http.StatusForbidden { t.Errorf("expected to pass security (not %d), got %d", http.StatusForbidden, rr.Code) } } }) } } // TestBrowserOnlyMiddleware_RequiresBrowserHeaders tests that browser-specific headers are required func TestBrowserOnlyMiddleware_RequiresBrowserHeaders(t *testing.T) { handler := setupTestServer(t) tests := []struct { name string htmxRequest string xRequestedWith string xBrowserRequest string wantCode int }{ { name: "no browser headers - blocked", htmxRequest: "", xRequestedWith: "", xBrowserRequest: "", wantCode: http.StatusForbidden, }, { name: "HX-Request header - allowed", htmxRequest: "true", xRequestedWith: "", xBrowserRequest: "", wantCode: http.StatusOK, }, { name: "X-Requested-With header - allowed", htmxRequest: "", xRequestedWith: "XMLHttpRequest", xBrowserRequest: "", wantCode: http.StatusOK, }, { name: "X-Browser-Request header - allowed", htmxRequest: "", xRequestedWith: "", xBrowserRequest: "true", wantCode: http.StatusOK, }, { name: "invalid HX-Request value - blocked", htmxRequest: "false", xRequestedWith: "", xBrowserRequest: "", wantCode: http.StatusForbidden, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := createValidContactRequest() // Clear default browser headers req.Header.Del("HX-Request") req.Header.Del("X-Requested-With") req.Header.Del("X-Browser-Request") // Set test headers if tt.htmxRequest != "" { req.Header.Set("HX-Request", tt.htmxRequest) } if tt.xRequestedWith != "" { req.Header.Set("X-Requested-With", tt.xRequestedWith) } if tt.xBrowserRequest != "" { req.Header.Set("X-Browser-Request", tt.xBrowserRequest) } rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != tt.wantCode { t.Errorf("expected status %d, got %d", tt.wantCode, rr.Code) } }) } } // TestInputValidation_EmailFormat tests email validation func TestInputValidation_EmailFormat(t *testing.T) { handler := setupTestServer(t) tests := []struct { name string email string ip string // Use unique IPs to avoid rate limiting wantCode int }{ { name: "valid email", email: "test@example.com", ip: "10.1.1.1", wantCode: http.StatusOK, }, { name: "valid email with subdomain", email: "user@mail.example.com", ip: "10.1.1.2", wantCode: http.StatusOK, }, { name: "invalid - no @", email: "notanemail", ip: "10.1.1.3", wantCode: http.StatusBadRequest, }, { name: "invalid - no domain", email: "test@", ip: "10.1.1.4", wantCode: http.StatusBadRequest, }, { name: "invalid - no TLD", email: "test@example", ip: "10.1.1.5", wantCode: http.StatusBadRequest, }, { name: "empty email", email: "", ip: "10.1.1.6", wantCode: http.StatusBadRequest, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Build request with test email formData := url.Values{} formData.Set("email", tt.email) formData.Set("name", "John Doe") formData.Set("subject", "Test") formData.Set("message", "This is a test message with sufficient length.") formData.Set("website", "") formData.Set("submit_time", fmt.Sprintf("%d", time.Now().Add(-5*time.Second).UnixMilli())) req := httptest.NewRequest(http.MethodPost, "/api/contact", strings.NewReader(formData.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)") req.Header.Set("Referer", "http://localhost:8080/") req.Header.Set("HX-Request", "true") req.Header.Set("X-Forwarded-For", tt.ip) // Use unique IP to avoid rate limiting rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != tt.wantCode { t.Errorf("expected status %d, got %d for email: %s", tt.wantCode, rr.Code, tt.email) } }) } } // TestInputValidation_MessageLength tests message length validation func TestInputValidation_MessageLength(t *testing.T) { handler := setupTestServer(t) tests := []struct { name string message string ip string // Use unique IPs to avoid rate limiting wantCode int }{ { name: "valid message - minimum length", message: "Short msg!", ip: "10.2.1.1", wantCode: http.StatusOK, }, { name: "valid message - normal length", message: "This is a normal length message that should pass validation.", ip: "10.2.1.2", wantCode: http.StatusOK, }, { name: "valid message - maximum length", message: strings.Repeat("a", 5000), ip: "10.2.1.3", wantCode: http.StatusOK, }, { name: "invalid - too long", message: strings.Repeat("a", 5001), ip: "10.2.1.4", wantCode: http.StatusBadRequest, }, { name: "invalid - empty", message: "", ip: "10.2.1.5", wantCode: http.StatusBadRequest, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Build request with test message formData := url.Values{} formData.Set("email", "test@example.com") formData.Set("name", "John Doe") formData.Set("subject", "Test Subject") formData.Set("message", tt.message) formData.Set("website", "") formData.Set("submit_time", fmt.Sprintf("%d", time.Now().Add(-5*time.Second).UnixMilli())) req := httptest.NewRequest(http.MethodPost, "/api/contact", strings.NewReader(formData.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)") req.Header.Set("Referer", "http://localhost:8080/") req.Header.Set("HX-Request", "true") req.Header.Set("X-Forwarded-For", tt.ip) // Use unique IP to avoid rate limiting rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != tt.wantCode { t.Errorf("expected status %d, got %d", tt.wantCode, rr.Code) } }) } } // TestInputValidation_RequiredFields tests that required fields are enforced func TestInputValidation_RequiredFields(t *testing.T) { handler := setupTestServer(t) tests := []struct { name string email string message string ip string // Use unique IPs to avoid rate limiting wantCode int }{ { name: "all required fields present", email: "test@example.com", message: "Valid message", ip: "10.3.1.1", wantCode: http.StatusOK, }, { name: "missing email", email: "", message: "Valid message", ip: "10.3.1.2", wantCode: http.StatusBadRequest, }, { name: "missing message", email: "test@example.com", message: "", ip: "10.3.1.3", wantCode: http.StatusBadRequest, }, { name: "both missing", email: "", message: "", ip: "10.3.1.4", wantCode: http.StatusBadRequest, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { formData := url.Values{} formData.Set("email", tt.email) formData.Set("name", "John Doe") formData.Set("subject", "Test") formData.Set("message", tt.message) formData.Set("website", "") formData.Set("submit_time", fmt.Sprintf("%d", time.Now().Add(-5*time.Second).UnixMilli())) req := httptest.NewRequest(http.MethodPost, "/api/contact", strings.NewReader(formData.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)") req.Header.Set("Referer", "http://localhost:8080/") req.Header.Set("HX-Request", "true") req.Header.Set("X-Forwarded-For", tt.ip) // Use unique IP to avoid rate limiting rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != tt.wantCode { t.Errorf("expected status %d, got %d", tt.wantCode, rr.Code) } }) } } // TestBotProtection_Honeypot tests honeypot field detection func TestBotProtection_Honeypot(t *testing.T) { handler := setupTestServer(t) tests := []struct { name string honeypot string wantCode int wantBlock bool }{ { name: "honeypot empty - human", honeypot: "", wantCode: http.StatusOK, wantBlock: false, }, { name: "honeypot filled - bot detected", honeypot: "http://spam.com", wantCode: http.StatusOK, // Returns 200 to fool bots wantBlock: true, }, { name: "honeypot with space - bot detected", honeypot: " ", wantCode: http.StatusOK, wantBlock: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { formData := url.Values{} formData.Set("email", "test@example.com") formData.Set("name", "John Doe") formData.Set("subject", "Test") formData.Set("message", "This is a valid test message.") formData.Set("website", tt.honeypot) formData.Set("submit_time", fmt.Sprintf("%d", time.Now().Add(-5*time.Second).UnixMilli())) req := httptest.NewRequest(http.MethodPost, "/api/contact", strings.NewReader(formData.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)") req.Header.Set("Referer", "http://localhost:8080/") req.Header.Set("HX-Request", "true") rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != tt.wantCode { t.Errorf("expected status %d, got %d", tt.wantCode, rr.Code) } // For honeypot triggers, we return success to fool bots // But we should verify the form wasn't actually processed if tt.wantBlock && rr.Code == http.StatusOK { // Success response for bots - they think it worked t.Logf("Honeypot triggered: bot received fake success (as intended)") } }) } } // TestBotProtection_Timing tests form submission timing validation func TestBotProtection_Timing(t *testing.T) { handler := setupTestServer(t) tests := []struct { name string delay time.Duration wantCode int }{ { name: "submitted too fast - 1 second", delay: 1 * time.Second, wantCode: http.StatusBadRequest, }, { name: "submitted at minimum time - 2 seconds", delay: 2 * time.Second, wantCode: http.StatusOK, }, { name: "normal submission - 5 seconds", delay: 5 * time.Second, wantCode: http.StatusOK, }, { name: "slow submission - 30 seconds", delay: 30 * time.Second, wantCode: http.StatusOK, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { submitTime := time.Now().Add(-tt.delay) formData := url.Values{} formData.Set("email", "test@example.com") formData.Set("name", "John Doe") formData.Set("subject", "Test") formData.Set("message", "This is a valid test message.") formData.Set("website", "") formData.Set("submit_time", fmt.Sprintf("%d", submitTime.UnixMilli())) req := httptest.NewRequest(http.MethodPost, "/api/contact", strings.NewReader(formData.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)") req.Header.Set("Referer", "http://localhost:8080/") req.Header.Set("HX-Request", "true") rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != tt.wantCode { t.Errorf("expected status %d, got %d (delay: %v)", tt.wantCode, rr.Code, tt.delay) } if tt.wantCode == http.StatusBadRequest { if !strings.Contains(rr.Body.String(), "take your time") { t.Errorf("expected 'take your time' message, got: %s", rr.Body.String()) } } }) } } // TestRateLimiting tests rate limit enforcement (5 requests per hour per IP) func TestRateLimiting(t *testing.T) { handler := setupTestServer(t) // Simulate requests from the same IP const maxRequests = 5 for i := 0; i < maxRequests+2; i++ { req := createValidContactRequest() // All requests from same IP req.RemoteAddr = "192.168.1.100:12345" rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if i < maxRequests { // First 5 requests should succeed if rr.Code != http.StatusOK { t.Errorf("request %d: expected status %d, got %d", i+1, http.StatusOK, rr.Code) } } else { // 6th and 7th requests should be rate limited if rr.Code != http.StatusTooManyRequests { t.Errorf("request %d: expected rate limit (status %d), got %d", i+1, http.StatusTooManyRequests, rr.Code) } if !strings.Contains(rr.Body.String(), "Too Many Requests") && !strings.Contains(rr.Body.String(), "too many") { t.Errorf("request %d: expected rate limit message, got: %s", i+1, rr.Body.String()) } } } } // TestRateLimiting_DifferentIPs tests that different IPs have separate rate limits func TestRateLimiting_DifferentIPs(t *testing.T) { handler := setupTestServer(t) ips := []string{ "192.168.1.1:12345", "192.168.1.2:12346", "10.0.0.1:12347", } // Each IP should be able to make 5 requests for _, ip := range ips { for i := 0; i < 5; i++ { req := createValidContactRequest() req.RemoteAddr = ip rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("IP %s request %d: expected status %d, got %d", ip, i+1, http.StatusOK, rr.Code) } } } } // TestComprehensiveSecurity_RealWorldScenario tests a realistic attack scenario func TestComprehensiveSecurity_RealWorldScenario(t *testing.T) { handler := setupTestServer(t) scenarios := []struct { name string setupReq func() *http.Request wantCode int description string }{ { name: "legitimate user submission", setupReq: func() *http.Request { return createValidContactRequest() }, wantCode: http.StatusOK, description: "Normal browser user should succeed", }, { name: "bot with curl trying to spam", setupReq: func() *http.Request { req := createValidContactRequest() req.Header.Set("User-Agent", "curl/7.68.0") return req }, wantCode: http.StatusForbidden, description: "Curl should be blocked by BrowserOnly middleware", }, { name: "bot filled honeypot field", setupReq: func() *http.Request { formData := url.Values{} formData.Set("email", "spammer@example.com") formData.Set("name", "Spammer") formData.Set("subject", "Spam") formData.Set("message", "Buy my product now!") formData.Set("website", "http://spam-site.com") // Bot filled honeypot! formData.Set("submit_time", fmt.Sprintf("%d", time.Now().Add(-5*time.Second).UnixMilli())) req := httptest.NewRequest(http.MethodPost, "/api/contact", strings.NewReader(formData.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", "Mozilla/5.0") req.Header.Set("Referer", "http://localhost:8080/") req.Header.Set("HX-Request", "true") return req }, wantCode: http.StatusOK, // Returns 200 to fool bot description: "Honeypot should catch bot but return fake success", }, { name: "automated script submitting too fast", setupReq: func() *http.Request { formData := url.Values{} formData.Set("email", "fast@example.com") formData.Set("name", "Fast Bot") formData.Set("subject", "Quick") formData.Set("message", "This was submitted instantly!") formData.Set("website", "") formData.Set("submit_time", fmt.Sprintf("%d", time.Now().Add(-500*time.Millisecond).UnixMilli())) // Too fast! req := httptest.NewRequest(http.MethodPost, "/api/contact", strings.NewReader(formData.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", "Mozilla/5.0") req.Header.Set("Referer", "http://localhost:8080/") req.Header.Set("HX-Request", "true") return req }, wantCode: http.StatusBadRequest, description: "Fast submission should be rejected", }, { name: "postman without browser headers", setupReq: func() *http.Request { req := createValidContactRequest() req.Header.Set("User-Agent", "PostmanRuntime/7.26.8") req.Header.Del("HX-Request") req.Header.Del("X-Requested-With") return req }, wantCode: http.StatusForbidden, description: "Postman should be blocked", }, { name: "request without referer/origin", setupReq: func() *http.Request { req := createValidContactRequest() req.Header.Del("Referer") req.Header.Del("Origin") return req }, wantCode: http.StatusForbidden, description: "No referer/origin should be blocked", }, } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { req := scenario.setupReq() rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != scenario.wantCode { t.Errorf("%s: expected status %d, got %d", scenario.description, scenario.wantCode, rr.Code) } t.Logf("✓ %s", scenario.description) }) } } // BenchmarkSecurityMiddleware benchmarks the performance impact of security middleware func BenchmarkSecurityMiddleware(b *testing.B) { handler := setupTestServer(&testing.T{}) b.ResetTimer() for i := 0; i < b.N; i++ { req := createValidContactRequest() rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) } } // BenchmarkBrowserOnlyMiddleware benchmarks just the BrowserOnly middleware func BenchmarkBrowserOnlyMiddleware(b *testing.B) { nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) handler := middleware.BrowserOnly(nextHandler) b.ResetTimer() for i := 0; i < b.N; i++ { req := createValidContactRequest() rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) } }