Files
cv-site/tests/security/contact_security_test.go
juanatsap c89b67a06d refactor: consolidate lang into constants, rename services to email
- Merge lang package into constants (add IsValidLang, ValidateLang, AllLangs)
- Rename internal/services to internal/email for consistency with pdf package
- Rename types to avoid redundancy: EmailService→Service, EmailConfig→Config
- Update all imports and references across codebase
- Delete internal/lang directory (functions moved to constants)
2025-12-06 17:05:17 +00:00

843 lines
23 KiB
Go

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/email"
"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 *email.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)
}
}