Files
volumen/internal/web/web_test.go
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

256 lines
8.6 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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")
}
}