package httpapi import ( "net/http" "net/http/httptest" "strings" "testing" ) func TestTrustedRealIPIgnoresHeadersWithoutAllowlist(t *testing.T) { t.Parallel() h := TrustedRealIP(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.RemoteAddr != "203.0.113.10:12345" { t.Fatalf("RemoteAddr = %q, want peer unchanged", r.RemoteAddr) } w.WriteHeader(http.StatusNoContent) })) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) req.RemoteAddr = "203.0.113.10:12345" req.Header.Set("X-Forwarded-For", "198.51.100.1") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("status = %d", rec.Code) } } func TestTrustedRealIPIgnoresHeadersFromUntrustedPeer(t *testing.T) { t.Parallel() h := TrustedRealIP([]string{"10.0.0.0/8"})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.RemoteAddr != "203.0.113.10:12345" { t.Fatalf("RemoteAddr = %q, want peer unchanged", r.RemoteAddr) } w.WriteHeader(http.StatusNoContent) })) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) req.RemoteAddr = "203.0.113.10:12345" req.Header.Set("X-Forwarded-For", "198.51.100.1") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("status = %d", rec.Code) } } func TestTrustedRealIPRewritesFromTrustedPeer(t *testing.T) { t.Parallel() h := TrustedRealIP([]string{"10.0.0.1"})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.RemoteAddr != "198.51.100.1" { t.Fatalf("RemoteAddr = %q, want client IP from XFF", r.RemoteAddr) } w.WriteHeader(http.StatusNoContent) })) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) req.RemoteAddr = "10.0.0.1:443" req.Header.Set("X-Forwarded-For", "198.51.100.1, 10.0.0.1") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("status = %d", rec.Code) } } func TestSecurityHeadersBaseline(t *testing.T) { t.Parallel() h := SecurityHeaders(false)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { t.Fatalf("X-Content-Type-Options = %q", got) } if got := rec.Header().Get("X-Frame-Options"); got != "DENY" { t.Fatalf("X-Frame-Options = %q", got) } if got := rec.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" { t.Fatalf("Referrer-Policy = %q", got) } if got := rec.Header().Get("Content-Security-Policy"); got != apiContentSecurityPolicy { t.Fatalf("Content-Security-Policy = %q, want %q", got, apiContentSecurityPolicy) } if got := rec.Header().Get("Content-Security-Policy-Report-Only"); got != "" { t.Fatalf("unexpected Report-Only CSP: %q", got) } if got := rec.Header().Get("Strict-Transport-Security"); got != "" { t.Fatalf("HSTS unexpectedly set: %q", got) } } func TestSecurityHeadersAPIContentSecurityPolicy(t *testing.T) { t.Parallel() if !strings.Contains(apiContentSecurityPolicy, "default-src 'none'") { t.Fatalf("API CSP missing default-src 'none': %q", apiContentSecurityPolicy) } if !strings.Contains(apiContentSecurityPolicy, "frame-ancestors 'none'") { t.Fatalf("API CSP missing frame-ancestors 'none': %q", apiContentSecurityPolicy) } if !strings.Contains(apiContentSecurityPolicy, "form-action 'none'") { t.Fatalf("API CSP missing form-action 'none': %q", apiContentSecurityPolicy) } if strings.Contains(apiContentSecurityPolicy, "'unsafe-inline'") || strings.Contains(apiContentSecurityPolicy, "'unsafe-eval'") { t.Fatalf("API CSP must not allow unsafe script: %q", apiContentSecurityPolicy) } if strings.Contains(apiContentSecurityPolicy, "ws:") || strings.Contains(apiContentSecurityPolicy, "wss:") { t.Fatalf("API CSP must not allow websocket schemes: %q", apiContentSecurityPolicy) } } func TestSecurityHeadersHSTSWhenSecure(t *testing.T) { t.Parallel() h := SecurityHeaders(true)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) if got := rec.Header().Get("Strict-Transport-Security"); got == "" { t.Fatal("expected HSTS when SessionSecure") } } func TestCORSAllowsConfiguredOriginOnly(t *testing.T) { t.Parallel() s := testAPIServer() s.Config.WebOrigin = "http://localhost:5174" h := s.Router() ok := httptest.NewRecorder() reqOK := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil) reqOK.Header.Set("Origin", "http://localhost:5174") reqOK.Header.Set("Access-Control-Request-Method", "POST") h.ServeHTTP(ok, reqOK) if got := ok.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5174" { t.Fatalf("allow origin = %q", got) } if got := ok.Header().Get("Access-Control-Allow-Credentials"); got != "true" { t.Fatalf("allow credentials = %q", got) } get := httptest.NewRecorder() reqGet := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) reqGet.Header.Set("Origin", "http://localhost:5174") h.ServeHTTP(get, reqGet) exposed := get.Header().Get("Access-Control-Expose-Headers") if !strings.Contains(strings.ToLower(exposed), "x-csrf-token") { t.Fatalf("expose headers = %q, want X-CSRF-Token", exposed) } if tok := get.Header().Get("X-CSRF-Token"); tok == "" { t.Fatal("expected X-CSRF-Token on credentialed GET (SPA cross-origin seed)") } twin := httptest.NewRecorder() reqTwin := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil) reqTwin.Header.Set("Origin", "http://127.0.0.1:5174") reqTwin.Header.Set("Access-Control-Request-Method", "POST") h.ServeHTTP(twin, reqTwin) if got := twin.Header().Get("Access-Control-Allow-Origin"); got != "http://127.0.0.1:5174" { t.Fatalf("loopback twin allow origin = %q", got) } bad := httptest.NewRecorder() reqBad := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil) reqBad.Header.Set("Origin", "https://evil.example") reqBad.Header.Set("Access-Control-Request-Method", "POST") h.ServeHTTP(bad, reqBad) if got := bad.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("unexpected allow origin for evil: %q", got) } }