package server import ( "bytes" "encoding/json" "io" "log/slog" "net" "net/http" "net/http/httptest" "net/netip" "os" "regexp" "strings" "testing" "time" "git.eeqj.de/sneak/routewatch/internal/config" "git.eeqj.de/sneak/routewatch/internal/database" "git.eeqj.de/sneak/routewatch/internal/logger" "git.eeqj.de/sneak/routewatch/internal/metrics" "git.eeqj.de/sneak/routewatch/internal/streamer" ) // The addresses the forwarding tests use: the client a proxy names in // X-Forwarded-For, the range of trusted proxies, a peer inside it, and a peer // outside it, which lies in testPrefixV4 so that an IP lookup of it finds a // route. reverseProxyPeer is a reverse proxy at a private address, as Docker // gives one. const ( forwardedClient = "203.0.113.9" trustedProxyRange = "10.0.0.0/8" trustedPeer = "10.0.0.2:1234" untrustedPeerAddr = "192.0.2.1" untrustedPeer = untrustedPeerAddr + ":1234" reverseProxyPeer = "172.19.0.14: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)) } // TestJSONAnswersPutNoUnitInAString sends a request to each JSON route and // checks each answer. Its @meta.execution_time_ms is a number, and none of its // strings is a number with a unit, such as "12 ms", "1.1 GB" or "1h18m34s" // (https://git.eeqj.de/sneak/routewatch/issues/199). The health check answers // 503, since the stream is not connected, and the last path matches no route. func TestJSONAnswersPutNoUnitInAString(t *testing.T) { t.Parallel() numberWithUnit := regexp.MustCompile( `^([0-9]+(\.[0-9]+)? ?(ns|µs|ms|s|m|h|B|kB|MB|GB|TB))+$`) s := newServerWithTestRoutes(t) s.resolver = &fakeResolver{} s.SetASNFetcher(fixedIntervalASNFetcher{interval: time.Second}) paths := []string{ statusJSONPath, statsPath, "/.well-known/healthcheck.json", "/api/v1/ip/" + untrustedPeerAddr, "/ip/" + untrustedPeerAddr, asJSONPath, "/api/v1/prefix/" + testPrefixV4, "/api/v1/search/as?q=google", "/api/v1/no-such-route", } for _, path := range paths { rec := httptest.NewRecorder() s.router.ServeHTTP(rec, httptest.NewRequestWithContext( t.Context(), http.MethodGet, path, nil)) var answer map[string]any err := json.Unmarshal(rec.Body.Bytes(), &answer) if err != nil { t.Errorf("%s: decoding the answer: %v; answer %s", path, err, rec.Body) continue } meta, _ := answer["@meta"].(map[string]any) if _, ok := meta["execution_time_ms"].(float64); !ok { t.Errorf("%s: @meta.execution_time_ms is not a number: %s", path, rec.Body) } for _, value := range stringsIn(answer) { if numberWithUnit.MatchString(value) { t.Errorf("%s: %q is a number with a unit in a string", path, value) } } } } // stringsIn returns every string in a decoded JSON value, at any depth. func stringsIn(value any) []string { var found []string switch v := value.(type) { case string: found = append(found, v) case []any: for _, item := range v { found = append(found, stringsIn(item)...) } case map[string]any: for _, item := range v { found = append(found, stringsIn(item)...) } } return found } // 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{"