// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package web import ( "bytes" "compress/gzip" "io" "net/http" "net/http/httptest" "net/netip" "strings" "testing" ) func TestClientIPDirect(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "203.0.113.9:5555" if got := ClientIP(req, nil); got != "203.0.113.9" { t.Fatalf("ClientIP = %q", got) } } func TestClientIPTrustProxy(t *testing.T) { trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")} req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "10.0.0.1:5555" // The proxy appends the address it accepted the connection from, so // the last entry is the client and everything to its left is // supplied by the caller. The forged 203.0.113.9 must not become the // rate-limit key. req.Header.Set("X-Forwarded-For", "203.0.113.9, 198.51.100.7") if got := ClientIP(req, trusted); got != "198.51.100.7" { t.Fatalf("ClientIP = %q", got) } // A single entry is the client's own address as the proxy saw it. req.Header.Set("X-Forwarded-For", "198.51.100.7") if got := ClientIP(req, trusted); got != "198.51.100.7" { t.Fatalf("ClientIP = %q", got) } // Without a trusted prefix the forwarded header is ignored. if got := ClientIP(req, nil); got != "10.0.0.1" { t.Fatalf("ClientIP = %q", got) } } // A client that can reach the listener directly cannot choose its own // rate-limit key by sending the header, because its peer address is not // one of the trusted proxies. func TestClientIPIgnoresAnUntrustedPeer(t *testing.T) { trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")} req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "203.0.113.9:5555" req.Header.Set("X-Forwarded-For", "198.51.100.7") if got := ClientIP(req, trusted); got != "203.0.113.9" { t.Fatalf("ClientIP = %q, want the peer's own address", got) } } func TestClientIPIPv6(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "[2001:db8::1]:4711" if got := ClientIP(req, nil); got != "2001:db8::1" { t.Fatalf("ClientIP = %q", got) } } func TestSecurityHeadersPublic(t *testing.T) { handler := SecurityHeaders(false)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil)) header := rec.Header() if header.Get("X-Content-Type-Options") != "nosniff" || header.Get("X-Frame-Options") != "DENY" { t.Fatalf("headers = %v", header) } csp := header.Get("Content-Security-Policy") if !strings.Contains(csp, "default-src 'self'") || strings.Contains(csp, "nonce-") { t.Fatalf("csp = %q", csp) } if header.Get("Cache-Control") != "" { t.Fatal("public responses must not be no-store") } } func TestSecurityHeadersAdmin(t *testing.T) { var nonce string handler := SecurityHeaders(true)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { nonce = Nonce(r.Context()) w.WriteHeader(http.StatusOK) })) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/", nil)) if nonce == "" { t.Fatal("nonce not generated") } csp := rec.Header().Get("Content-Security-Policy") if !strings.Contains(csp, "script-src 'self' 'nonce-"+nonce+"'") { t.Fatalf("csp = %q", csp) } if strings.Contains(csp, "script-src 'self';") { t.Fatalf("base script-src not replaced: %q", csp) } if rec.Header().Get("Cache-Control") != "no-store" { t.Fatal("admin responses must be no-store") } if rec.Header().Get("Strict-Transport-Security") == "" { t.Fatal("HSTS missing for secure deployments") } } func TestGzipCompressesLargeBodies(t *testing.T) { body := bytes.Repeat([]byte("hello world "), 100) handler := Gzip(500)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(body) })) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Accept-Encoding", "gzip") handler.ServeHTTP(rec, req) if rec.Header().Get("Content-Encoding") != "gzip" { t.Fatalf("content-encoding = %q", rec.Header().Get("Content-Encoding")) } zr, err := gzip.NewReader(bytes.NewReader(rec.Body.Bytes())) if err != nil { t.Fatalf("gzip reader: %v", err) } got, err := io.ReadAll(zr) if err != nil { t.Fatalf("read: %v", err) } if !bytes.Equal(got, body) { t.Fatal("compressed body differs") } } func TestGzipSkipsSmallBodies(t *testing.T) { handler := Gzip(500)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("small")) })) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Accept-Encoding", "gzip") handler.ServeHTTP(rec, req) if rec.Header().Get("Content-Encoding") != "" { t.Fatal("small body compressed") } if rec.Body.String() != "small" { t.Fatalf("body = %q", rec.Body.String()) } } func TestGzipSkippedWithoutAcceptEncoding(t *testing.T) { body := bytes.Repeat([]byte("x"), 2000) handler := Gzip(500)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(body) })) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) if rec.Header().Get("Content-Encoding") != "" { t.Fatal("compressed without Accept-Encoding") } if !bytes.Equal(rec.Body.Bytes(), body) { t.Fatal("body altered") } } func TestGzipPreserves304(t *testing.T) { handler := Gzip(10)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotModified) })) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Accept-Encoding", "gzip") handler.ServeHTTP(rec, req) if rec.Code != http.StatusNotModified || rec.Body.Len() != 0 { t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String()) } } // A state-changing request a browser sent from another origin is refused // before any handler runs, and a request with no browser headers at all // (a CLI client) is allowed through. func TestCrossOriginRefusesForeignBrowserRequests(t *testing.T) { handler := CrossOrigin()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) foreign := httptest.NewRequest(http.MethodPost, "/admin/login", nil) foreign.Header.Set("Sec-Fetch-Site", "cross-site") foreign.Header.Set("Origin", "https://evil.example") rec := httptest.NewRecorder() handler.ServeHTTP(rec, foreign) if rec.Code != http.StatusForbidden { t.Fatalf("cross-site POST = %d, want 403", rec.Code) } if !strings.Contains(rec.Body.String(), "cross_origin") { t.Fatalf("body = %s", rec.Body.String()) } foreign.Header.Set("Sec-Fetch-Site", "same-origin") rec = httptest.NewRecorder() handler.ServeHTTP(rec, foreign) if rec.Code != http.StatusNoContent { t.Fatalf("same-origin POST = %d, want 204", rec.Code) } cli := httptest.NewRequest(http.MethodPost, "/api/volumen/posts", nil) rec = httptest.NewRecorder() handler.ServeHTTP(rec, cli) if rec.Code != http.StatusNoContent { t.Fatalf("a request with no browser headers = %d, want 204", rec.Code) } read := httptest.NewRequest(http.MethodGet, "/admin/", nil) read.Header.Set("Sec-Fetch-Site", "cross-site") rec = httptest.NewRecorder() handler.ServeHTTP(rec, read) if rec.Code != http.StatusNoContent { t.Fatalf("cross-site GET = %d, want 204: safe methods are not refused", rec.Code) } } // "gzip;q=0" is an explicit refusal: the response must stay plain even // though the header contains the substring "gzip". func TestGzipHonoursZeroQuality(t *testing.T) { body := bytes.Repeat([]byte("hello world "), 100) handler := Gzip(500)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(body) })) for _, header := range []string{"gzip;q=0", "br, gzip;q=0.000", "identity, gzip;q=0"} { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Accept-Encoding", header) handler.ServeHTTP(rec, req) if enc := rec.Header().Get("Content-Encoding"); enc == "gzip" { t.Fatalf("Accept-Encoding %q was answered with gzip", header) } if !bytes.Equal(rec.Body.Bytes(), body) { t.Fatalf("Accept-Encoding %q: body was transformed", header) } } // A positive quality still compresses. rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Accept-Encoding", "br, gzip;q=0.5") handler.ServeHTTP(rec, req) if rec.Header().Get("Content-Encoding") != "gzip" { t.Fatal("gzip;q=0.5 was not answered with gzip") } }