c89b67a06d
- 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)
843 lines
23 KiB
Go
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)
|
|
}
|
|
}
|