Files
scratchbox/internal/http/middleware_test.go
T
s1d3sw1ped ad4355df17
CI / Go Tests (push) Successful in 15s
CI / Build (push) Successful in 9s
Format / gofmt (push) Successful in 7s
Release Artifacts / Validate release tag (push) Successful in 2s
Release Artifacts / Build and release executables (push) Successful in 15s
Release Artifacts / Build and release Docker image (push) Successful in 28s
Heavy security and file splitting
2026-06-01 20:15:28 -05:00

381 lines
13 KiB
Go

package httpapi
import (
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"time"
)
func TestAllowlistMiddlewareDeniesAndAllowsByIP(t *testing.T) {
t.Parallel()
logger := log.New(io.Discard, "", 0)
allowed := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
mw := AllowlistMiddleware(allowed, false, nil, logger)
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
handler := mw(next)
reqAllowed := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
reqAllowed.RemoteAddr = "10.1.2.3:1234"
recAllowed := httptest.NewRecorder()
handler.ServeHTTP(recAllowed, reqAllowed)
if recAllowed.Code != http.StatusNoContent {
t.Fatalf("allowed request status = %d, want %d", recAllowed.Code, http.StatusNoContent)
}
reqDenied := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
reqDenied.RemoteAddr = "192.168.10.5:1234"
recDenied := httptest.NewRecorder()
handler.ServeHTTP(recDenied, reqDenied)
if recDenied.Code != http.StatusForbidden {
t.Fatalf("denied request status = %d, want %d", recDenied.Code, http.StatusForbidden)
}
}
func TestAllowlistMiddlewareHonorsProxyHeaderWhenTrusted(t *testing.T) {
t.Parallel()
logger := log.New(io.Discard, "", 0)
allowed := []netip.Prefix{netip.MustParsePrefix("203.0.113.5/32")}
trustedProxies := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
mw := AllowlistMiddleware(allowed, true, trustedProxies, logger)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
req.RemoteAddr = "10.0.0.10:9999"
req.Header.Set("X-Forwarded-For", "203.0.113.5, 198.51.100.2")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
}
func TestRateLimitMiddlewareKeysByClientIP(t *testing.T) {
t.Parallel()
limiter := NewRateLimiter(60, 1)
handler := RateLimitMiddleware(limiter, false, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req1 := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
req1.RemoteAddr = "198.51.100.7:1111"
rec1 := httptest.NewRecorder()
handler.ServeHTTP(rec1, req1)
if rec1.Code != http.StatusNoContent {
t.Fatalf("first request status = %d, want %d", rec1.Code, http.StatusNoContent)
}
req2 := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
req2.RemoteAddr = "198.51.100.7:2222"
rec2 := httptest.NewRecorder()
handler.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusTooManyRequests {
t.Fatalf("second same-ip request status = %d, want %d", rec2.Code, http.StatusTooManyRequests)
}
req3 := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
req3.RemoteAddr = "198.51.100.8:3333"
rec3 := httptest.NewRecorder()
handler.ServeHTTP(rec3, req3)
if rec3.Code != http.StatusNoContent {
t.Fatalf("different-ip request status = %d, want %d", rec3.Code, http.StatusNoContent)
}
}
func TestAllowlistMiddlewareNoRestrictionsReturnsNext(t *testing.T) {
t.Parallel()
handler := AllowlistMiddleware(nil, false, nil, log.New(io.Discard, "", 0))(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusAccepted)
}))
req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
req.RemoteAddr = "not-a-valid-remote-addr"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusAccepted)
}
}
func TestAllowlistMiddlewareRejectsUnknownClientIP(t *testing.T) {
t.Parallel()
logger := log.New(io.Discard, "", 0)
allowed := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
handler := AllowlistMiddleware(allowed, false, nil, logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
req.RemoteAddr = "garbage"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
}
func TestRateLimitMiddlewareUnknownIP(t *testing.T) {
t.Parallel()
limiter := NewRateLimiter(60, 1)
handler := RateLimitMiddleware(limiter, false, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
req.RemoteAddr = "bad-addr"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusTooManyRequests)
}
}
func TestRateLimitMiddleware429PayloadAndRetryHeader(t *testing.T) {
t.Parallel()
limiter := NewRateLimiter(1, 1)
handler := RateLimitMiddleware(limiter, false, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req1 := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
req1.RemoteAddr = "198.18.0.1:1111"
handler.ServeHTTP(httptest.NewRecorder(), req1)
req2 := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
req2.RemoteAddr = "198.18.0.1:1112"
rec2 := httptest.NewRecorder()
handler.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusTooManyRequests {
t.Fatalf("status = %d, want %d", rec2.Code, http.StatusTooManyRequests)
}
if got := rec2.Header().Get("Retry-After"); got != "60" {
t.Fatalf("Retry-After = %q, want %q", got, "60")
}
if ct := rec2.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Fatalf("Content-Type = %q, want application/json", ct)
}
var payload map[string]string
if err := json.Unmarshal(rec2.Body.Bytes(), &payload); err != nil {
t.Fatalf("json unmarshal error = %v", err)
}
if payload["error"] == "" {
t.Fatalf("expected error payload")
}
}
func TestRateLimiterPrunesStaleVisitors(t *testing.T) {
t.Parallel()
limiter := NewRateLimiter(60, 1)
limiter.ttl = time.Nanosecond
limiter.visitors["old"] = &visitor{lastSeen: time.Now().Add(-time.Hour)}
if !limiter.Allow("new") {
t.Fatalf("Allow(new) = false, want true")
}
if _, ok := limiter.visitors["old"]; ok {
t.Fatalf("stale visitor should be removed")
}
}
func TestExtractClientIPFallbacks(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "203.0.113.9"
ip, ok := extractClientIP(req, false, nil)
if !ok || ip.String() != "203.0.113.9" {
t.Fatalf("extract direct ip = (%v,%v), want (203.0.113.9,true)", ip, ok)
}
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
req2.RemoteAddr = "192.0.2.50:8080"
req2.Header.Set("X-Forwarded-For", "bad,198.51.100.1")
req2.Header.Set("X-Real-IP", "198.51.100.5")
ip2, ok2 := extractClientIP(req2, true, []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")})
if !ok2 || ip2.String() != "198.51.100.5" {
t.Fatalf("extract x-real-ip = (%v,%v), want (198.51.100.5,true)", ip2, ok2)
}
req3 := httptest.NewRequest(http.MethodGet, "/", nil)
req3.RemoteAddr = "still-not-an-ip"
if _, ok3 := extractClientIP(req3, false, nil); ok3 {
t.Fatalf("extractClientIP() ok = true, want false")
}
}
func TestExtractClientIPIgnoresHeadersFromUntrustedProxy(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "203.0.113.10:8080"
req.Header.Set("X-Forwarded-For", "198.51.100.5")
ip, ok := extractClientIP(req, true, []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")})
if !ok || ip.String() != "203.0.113.10" {
t.Fatalf("extractClientIP() = (%v,%v), want (203.0.113.10,true)", ip, ok)
}
}
func TestAccessLogMiddlewareWritesStructuredLog(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
handler := AccessLogMiddleware(logger, false, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte("ok"))
}))
req := httptest.NewRequest(http.MethodPost, "/api/scratch?ttl=1h", nil)
req.RemoteAddr = "198.51.100.12:4444"
req.Header.Set("User-Agent", "middleware-test")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
got := buf.String()
if !strings.Contains(got, "method=POST") {
t.Fatalf("missing method field in log: %q", got)
}
if !strings.Contains(got, "path=/api/scratch?ttl=1h") {
t.Fatalf("missing path field in log: %q", got)
}
if !strings.Contains(got, "status=201") {
t.Fatalf("missing status field in log: %q", got)
}
if !strings.Contains(got, "bytes=2") {
t.Fatalf("missing bytes field in log: %q", got)
}
if !strings.Contains(got, "ip=198.51.100.12") {
t.Fatalf("missing ip field in log: %q", got)
}
}
func TestAccessLogMiddlewareSkipsConfigAndStaticPaths(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
handler := AccessLogMiddleware(logger, false, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
reqConfig := httptest.NewRequest(http.MethodGet, "/api/config", nil)
reqConfig.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), reqConfig)
reqStatic := httptest.NewRequest(http.MethodGet, "/static/app.js", nil)
reqStatic.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), reqStatic)
reqAsset := httptest.NewRequest(http.MethodGet, "/assets/logo.svg", nil)
reqAsset.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), reqAsset)
reqScratchView := httptest.NewRequest(http.MethodGet, "/s/abc123", nil)
reqScratchView.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), reqScratchView)
reqUpload := httptest.NewRequest(http.MethodGet, "/u", nil)
reqUpload.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), reqUpload)
if buf.Len() != 0 {
t.Fatalf("expected no access logs for skipped paths, got %q", buf.String())
}
}
func TestAccessLogMiddlewareOnlyAllowsConfiguredRoutes(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
handler := AccessLogMiddleware(logger, false, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
disallowed := httptest.NewRequest(http.MethodGet, "/api/scratch/some-id", nil)
disallowed.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), disallowed)
if buf.Len() != 0 {
t.Fatalf("expected no access log for disallowed route, got %q", buf.String())
}
allowed := httptest.NewRequest(http.MethodGet, "/api/raw/some-id", nil)
allowed.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), allowed)
if !strings.Contains(buf.String(), "method=GET path=/api/raw/some-id") {
t.Fatalf("expected access log for allowed route, got %q", buf.String())
}
}
// TestSecurityHeadersMiddlewareSetsDefaults verifies the new middleware sets expected headers
// (X-Content-Type-Options, Referrer, CSP, HSTS conditional, etc.) on wrapped responses.
func TestSecurityHeadersMiddlewareSetsDefaults(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
// HSTS enabled (default)
handler := SecurityHeadersMiddleware(true, next)
req := httptest.NewRequest(http.MethodGet, "/anything", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d want 200", rec.Code)
}
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options=%q want nosniff", got)
}
if got := rec.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" {
t.Fatalf("Referrer-Policy=%q want strict-origin-when-cross-origin", got)
}
if got := rec.Header().Get("X-Frame-Options"); got != "DENY" {
t.Fatalf("X-Frame-Options=%q want DENY", got)
}
hsts := rec.Header().Get("Strict-Transport-Security")
if !strings.Contains(hsts, "max-age=31536000") {
t.Fatalf("HSTS (enabled)=%q missing max-age", hsts)
}
csp := rec.Header().Get("Content-Security-Policy")
if csp == "" || !strings.Contains(csp, "default-src 'self'") || !strings.Contains(csp, "frame-ancestors 'none'") {
t.Fatalf("CSP=%q missing required", csp)
}
pp := rec.Header().Get("Permissions-Policy")
if !strings.Contains(pp, "camera=()") {
t.Fatalf("Permissions-Policy=%q", pp)
}
if ct := rec.Header().Get("Content-Type"); ct != "text/plain" {
t.Fatalf("Content-Type was overwritten? got %q", ct)
}
// HSTS disabled
handlerNoHSTS := SecurityHeadersMiddleware(false, next)
rec2 := httptest.NewRecorder()
handlerNoHSTS.ServeHTTP(rec2, req)
if hsts2 := rec2.Header().Get("Strict-Transport-Security"); hsts2 != "" {
t.Fatalf("HSTS (disabled) should be absent, got %q", hsts2)
}
// other headers still present
if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options (no hsts)=%q want nosniff", got)
}
}