package server import ( "encoding/json" "net/http" "net/http/httptest" "net/netip" "strings" "testing" "time" ) // The addresses the forwarding tests use: the client a proxy names in // X-Forwarded-For, a peer inside the trusted range of // TestRealIPMiddlewareTrustsOnlyConfiguredProxies, and a peer outside it, // which lies in testPrefixV4 so that an IP lookup of it finds a route. const ( forwardedClient = "203.0.113.9" trustedPeer = "10.0.0.2:1234" untrustedPeerAddr = "192.0.2.1" untrustedPeer = untrustedPeerAddr + ":1234" ) // TestResponsesCarrySecurityHeaders checks that a page, a JSON route and a // path no route matches each answer with the security headers REPO_POLICIES.md // requires, and that the Content-Security-Policy allows no inline content // without a nonce (https://git.eeqj.de/sneak/routewatch/issues/108). func TestResponsesCarrySecurityHeaders(t *testing.T) { t.Parallel() s := newServerWithTestRoutes(t) headers := []string{ "Strict-Transport-Security", "Content-Security-Policy", "X-Frame-Options", "X-Content-Type-Options", "Referrer-Policy", "Permissions-Policy", } for _, path := range []string{"/", statsPath, "/no-such-page"} { rec := httptest.NewRecorder() s.router.ServeHTTP(rec, httptest.NewRequestWithContext( t.Context(), http.MethodGet, path, nil)) for _, name := range headers { if rec.Header().Get(name) == "" { t.Errorf("%s: no %s header", path, name) } } policy := rec.Header().Get("Content-Security-Policy") if strings.Contains(policy, "'unsafe-") { t.Errorf("%s: Content-Security-Policy %q allows unsafe content", path, policy) } } } // TestJSONValidationKeeps4xxWithoutBody wraps a handler that answers 400 // without a body in JSONValidationMiddleware and checks that the JSON error // body keeps the 400 and gives the status's own text. Before the fix it said // "Internal server error" // (https://git.eeqj.de/sneak/routewatch/issues/111). func TestJSONValidationKeeps4xxWithoutBody(t *testing.T) { t.Parallel() handler := JSONValidationMiddleware(http.HandlerFunc( func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) })) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequestWithContext( t.Context(), http.MethodGet, "/", nil)) const name = "400 without a body" if rec.Code != http.StatusBadRequest { t.Errorf("%s: status %d, want %d; body %s", name, rec.Code, http.StatusBadRequest, rec.Body) } checkJSONErrorCode(t, name, rec, http.StatusBadRequest) checkJSONErrorMessage(t, name, rec, http.StatusText(http.StatusBadRequest)) } // TestPagesCarryTheCSPNonce checks that every script and style block of each // page carries the nonce its Content-Security-Policy header names, so that the // browser runs and applies them, that each answer has a nonce of its own, and // that no page uses a style attribute, which the policy does not allow. func TestPagesCarryTheCSPNonce(t *testing.T) { t.Parallel() s := newServerWithTestRoutes(t) _, err := s.db.GetOrCreateASN(64500, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) if err != nil { t.Fatalf("GetOrCreateASN: %v", err) } paths := []string{ "/", "/status", asPagePath, "/prefix/" + testPrefixV4, prefixLengthPagePath, "/prefixlength6/32", } seen := make(map[string]bool) for _, path := range paths { rec := httptest.NewRecorder() s.router.ServeHTTP(rec, httptest.NewRequestWithContext( t.Context(), http.MethodGet, path, nil)) if rec.Code != http.StatusOK { t.Errorf("%s: status %d, want %d; body %s", path, rec.Code, http.StatusOK, rec.Body) continue } policy := rec.Header().Get("Content-Security-Policy") _, nonce, found := strings.Cut(policy, "'nonce-") if !found { t.Errorf("%s: Content-Security-Policy %q names no nonce", path, policy) continue } nonce, _, _ = strings.Cut(nonce, "'") if seen[nonce] { t.Errorf("%s: nonce %q was already used for another answer", path, nonce) } seen[nonce] = true page := rec.Body.String() for _, tag := range []string{"