158 lines
5.7 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|