Files
descrybe/apps/api/internal/httpapi/security_middleware_test.go
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

158 lines
5.7 KiB
Go

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)
}
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)
}
}