256 lines
8.6 KiB
Go
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")
|
||
|
|
}
|
||
|
|
}
|