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) } }