mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 04:52:30 +00:00
63bfa3d910
Closes #1561. Follow-up to #1551. ## Why #1551 added `Cache-Control: no-store` to all `/api/*` responses. That's sufficient for CDNs that honour origin headers (Varnish, nginx). It is **not** sufficient for Cloudflare zones where Cache Rules / Page Rules override origin Cache-Control. Field evidence from the meshat.se diagnosis (2026-06-04): observers behind Cloudflare were returning `cf-cache-status: HIT` with `age` up to ~6 hours despite the origin emitting `no-store`. The CDN was caching per zone policy and ignoring the upstream directive — exactly the failure mode #1551 cannot reach. The application has no way to inject CDN rules; the only durable fix is operator-side. This PR makes that operator step discoverable and verifiable. ## What ### Server-side detection (log-only) `cmd/server/cdn_detection.go` adds a middleware wired into the `/api/*` chain after `noStoreAPIMiddleware`. On the **first** request bearing any CDN-typical header (`CF-Connecting-IP`, `CF-Ray`, `X-Forwarded-For`, `X-Real-IP`, `Fastly-Client-IP`, `True-Client-IP`) it logs: ``` [security] WARNING: detected request via CDN (CF-Ray header present). Ensure /api/* is bypassed in your CDN config — see docs/deployment-behind-cdn.md. Cached API responses cause observer-flap and incorrect dashboards. ``` `sync.Once` guarantees the warning fires at most once per process boot. The middleware never blocks, never modifies the response, never adds headers. Detection is observational only — operators who run behind a CDN without bypass have a real bug; the warning is appropriate. ### Operator documentation `docs/deployment.md` gains a new **"Behind a CDN (Cloudflare, Fastly)"** section covering: 1. Curl verification command + healthy vs unhealthy output examples 2. Cloudflare Cache Rule creation (URI Path starts-with `/api/` → Bypass cache) 3. Legacy Page Rules equivalent 4. Fastly note 5. Re-verification 6. Meaning of the startup log warning 7. Why we can't fix this server-side `docs/deployment-behind-cdn.md` is the canonical path the log message references — it's a short TL;DR that links back to the full section. ### Healthcheck script `scripts/check-cdn-bypass.sh` — POSIX sh, no dependencies beyond curl + grep + awk. Operators run: ```sh scripts/check-cdn-bypass.sh https://your-domain.example.com ``` Exits `0` with `OK: no CDN caching detected ...` or `1` with a precise diagnostic naming the offending header (`cf-cache-status: HIT` or stale `age`). ## TDD - **Red commit `e90ccaba`** (`test(security): RED ...`) — `cmd/server/cdn_detection_test.go` (4 Go tests + 6 subtests for each header) and `scripts/test-check-cdn-bypass.sh` (3 shell harness cases). Middleware stub returns `next` unchanged so tests compile and fail on assertions, not build errors. - **Green commit `5e6a60b5`** (`feat(security): GREEN ...`) — real middleware, wiring in `routes.go`, healthcheck script, doc. ## Deliverables | File | Status | Purpose | |------|--------|---------| | `cmd/server/cdn_detection.go` | new | middleware + sync.Once warning | | `cmd/server/cdn_detection_test.go` | new | 4 Go tests (1 stand-alone + 1 silence + 1 once + 1 table-driven over 6 headers) | | `cmd/server/routes.go` | modified | `r.Use(cdnDetectionMiddleware)` after no-store | | `docs/deployment.md` | modified | TOC entry + "Behind a CDN" section | | `docs/deployment-behind-cdn.md` | new | canonical path referenced by log message + script output | | `scripts/check-cdn-bypass.sh` | new | operator-runnable healthcheck | | `scripts/test-check-cdn-bypass.sh` | new | shell harness with fake curl | ## What this PR explicitly does NOT do - Does not block requests based on CDN detection (log-only). - Does not enforce CDN bypass (impossible — operator-controlled). - Does not spoof, strip or modify CDN headers. - Does not add CSP / HSTS / other security headers (out of scope). - Warning is not configurable — operators behind a CDN without bypass have a real bug, surfacing it is correct. ## Verification - `go test ./...` in `cmd/server/` — full suite green. - `sh scripts/test-check-cdn-bypass.sh` — 3/3 pass. - Preflight checklist — all 11 gates clean (PII, branch scope, red commit, CSS vars, CSS self-fallback, LIKE-on-JSON, sync migration, async-migration annotation, XSS sinks, img/SVG ratio, themed-img/SVG, fixture coverage). --------- Co-authored-by: openclaw-bot <bot@openclaw.local> Co-authored-by: clawbot <bot@clawbot.invalid>
277 lines
8.9 KiB
Go
277 lines
8.9 KiB
Go
package main
|
|
|
|
// Issue #1561: When the server is fronted by a CDN (Cloudflare, Fastly,
|
|
// Akamai) we cannot guarantee /api/* responses are not cached unless
|
|
// the operator configures a bypass rule. Detect CDN-specific request
|
|
// headers at the first such request and log a one-shot warning
|
|
// pointing the operator at the bypass doc.
|
|
//
|
|
// Contract:
|
|
// - Warning logs ONLY when a CDN-specific header is present
|
|
// (CF-Connecting-IP, CF-Ray, Fastly-Client-IP, True-Client-IP).
|
|
// - Generic reverse-proxy headers (X-Forwarded-For, X-Real-IP) MUST
|
|
// NOT trigger the warning — every nginx/Caddy/Traefik/k8s install
|
|
// sets those, so warning on them defeats the entire signal.
|
|
// - Warning logs at most ONCE per process boot (sync.Once), even
|
|
// under concurrent first-request load.
|
|
// - Middleware NEVER blocks the request — it always calls
|
|
// next.ServeHTTP.
|
|
|
|
import (
|
|
"bytes"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
)
|
|
|
|
// resetCDNDetectionOnce restores a fresh sync.Once so each test starts
|
|
// from a clean "have not warned yet" state.
|
|
func resetCDNDetectionOnce() {
|
|
cdnWarnOnce = sync.Once{}
|
|
cdnWarned.Store(false)
|
|
}
|
|
|
|
// runWithCDNMiddleware fires the request through the middleware and
|
|
// returns (log output, whether next was called). The sentinel proves
|
|
// the middleware did not silently drop the request.
|
|
func runWithCDNMiddleware(t *testing.T, req *http.Request) (string, bool) {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
prev := log.Writer()
|
|
log.SetOutput(&buf)
|
|
defer log.SetOutput(prev)
|
|
|
|
nextCalled := false
|
|
h := cdnDetectionMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
nextCalled = true
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("middleware must not block request; got status %d", w.Code)
|
|
}
|
|
return buf.String(), nextCalled
|
|
}
|
|
|
|
func TestCDNDetection_LogsOnCFRayHeader(t *testing.T) {
|
|
resetCDNDetectionOnce()
|
|
req := httptest.NewRequest("GET", "/api/observers", nil)
|
|
req.Header.Set("CF-Ray", "abc123-LAX")
|
|
|
|
out, nextCalled := runWithCDNMiddleware(t, req)
|
|
|
|
if !nextCalled {
|
|
t.Fatal("middleware did not call next handler")
|
|
}
|
|
if !strings.Contains(out, "detected request via CDN") {
|
|
t.Errorf("expected log to contain 'detected request via CDN', got: %q", out)
|
|
}
|
|
if !strings.Contains(out, "deployment-behind-cdn") {
|
|
t.Errorf("expected log to reference deployment-behind-cdn doc, got: %q", out)
|
|
}
|
|
}
|
|
|
|
func TestCDNDetection_SilentWithoutCDNHeader(t *testing.T) {
|
|
resetCDNDetectionOnce()
|
|
req := httptest.NewRequest("GET", "/api/observers", nil)
|
|
// No CDN-typical headers set.
|
|
|
|
out, nextCalled := runWithCDNMiddleware(t, req)
|
|
|
|
if !nextCalled {
|
|
t.Fatal("middleware did not call next handler")
|
|
}
|
|
if strings.Contains(out, "detected request via CDN") {
|
|
t.Errorf("expected no CDN warning without CDN headers, got: %q", out)
|
|
}
|
|
}
|
|
|
|
// Regression for round-1 adversarial finding: generic reverse-proxy
|
|
// headers must NOT trigger the warning. Every nginx/Caddy/Traefik/
|
|
// k8s-ingress reverse proxy sets X-Forwarded-For and X-Real-IP, so
|
|
// flagging them produces a false positive on every reverse-proxied
|
|
// install and trains operators to ignore the warning.
|
|
func TestCDNDetection_SilentOnReverseProxyHeadersAlone(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
header string
|
|
}{
|
|
{"x-forwarded-for-alone", "X-Forwarded-For"},
|
|
{"x-real-ip-alone", "X-Real-IP"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
resetCDNDetectionOnce()
|
|
req := httptest.NewRequest("GET", "/api/observers", nil)
|
|
req.Header.Set(tc.header, "10.0.0.1")
|
|
// No CDN-specific headers — just the generic reverse-proxy one.
|
|
|
|
out, nextCalled := runWithCDNMiddleware(t, req)
|
|
|
|
if !nextCalled {
|
|
t.Fatal("middleware did not call next handler")
|
|
}
|
|
if strings.Contains(out, "detected request via CDN") {
|
|
t.Errorf("header %s alone must NOT trigger CDN warning (would false-positive every nginx/k8s deploy); got: %q", tc.header, out)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// When a CDN-specific header is present alongside generic proxy
|
|
// headers (common: Cloudflare → nginx → app), the warning still fires.
|
|
func TestCDNDetection_LogsWhenCDNHeaderAccompaniesProxyHeaders(t *testing.T) {
|
|
resetCDNDetectionOnce()
|
|
req := httptest.NewRequest("GET", "/api/observers", nil)
|
|
req.Header.Set("X-Forwarded-For", "10.0.0.1")
|
|
req.Header.Set("X-Real-IP", "10.0.0.1")
|
|
req.Header.Set("CF-Connecting-IP", "1.2.3.4")
|
|
|
|
out, nextCalled := runWithCDNMiddleware(t, req)
|
|
|
|
if !nextCalled {
|
|
t.Fatal("middleware did not call next handler")
|
|
}
|
|
if !strings.Contains(out, "detected request via CDN") {
|
|
t.Errorf("expected CDN warning when CF-Connecting-IP present alongside proxy headers; got: %q", out)
|
|
}
|
|
}
|
|
|
|
func TestCDNDetection_LogsOnlyOnce(t *testing.T) {
|
|
resetCDNDetectionOnce()
|
|
|
|
var buf bytes.Buffer
|
|
prev := log.Writer()
|
|
log.SetOutput(&buf)
|
|
defer log.SetOutput(prev)
|
|
|
|
nextCalled := 0
|
|
h := cdnDetectionMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
nextCalled++
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
for i := 0; i < 3; i++ {
|
|
req := httptest.NewRequest("GET", "/api/observers", nil)
|
|
req.Header.Set("CF-Ray", "abc123")
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
}
|
|
|
|
if nextCalled != 3 {
|
|
t.Fatalf("middleware must call next on every request; got %d calls, want 3", nextCalled)
|
|
}
|
|
got := strings.Count(buf.String(), "detected request via CDN")
|
|
if got != 1 {
|
|
t.Errorf("expected CDN warning exactly once across multiple requests; got %d in output: %q", got, buf.String())
|
|
}
|
|
}
|
|
|
|
// Each genuinely CDN-specific header should trip the detector on its
|
|
// own. X-Forwarded-For / X-Real-IP are NOT in this set — see the
|
|
// negative test TestCDNDetection_SilentOnReverseProxyHeadersAlone.
|
|
func TestCDNDetection_RecognizesAllCommonCDNHeaders(t *testing.T) {
|
|
headers := []string{
|
|
"CF-Connecting-IP",
|
|
"CF-Ray",
|
|
"Fastly-Client-IP",
|
|
"True-Client-IP",
|
|
}
|
|
for _, h := range headers {
|
|
t.Run(h, func(t *testing.T) {
|
|
resetCDNDetectionOnce()
|
|
req := httptest.NewRequest("GET", "/api/observers", nil)
|
|
req.Header.Set(h, "1.2.3.4")
|
|
out, nextCalled := runWithCDNMiddleware(t, req)
|
|
if !nextCalled {
|
|
t.Fatal("middleware did not call next handler")
|
|
}
|
|
if !strings.Contains(out, "detected request via CDN") {
|
|
t.Errorf("header %s should trip CDN detection; log was: %q", h, out)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Round-1 KB finding #2: sync.Once is what keeps the log from
|
|
// spamming — verify it holds under concurrent first-request load.
|
|
// CI runs `go test -race`, so this also stresses the underlying
|
|
// primitive for data races. Without -race, the assertion still
|
|
// catches a plain bool / non-atomic implementation.
|
|
func TestCDNDetectionMiddlewareConcurrentFirstRequestLogsOnce(t *testing.T) {
|
|
resetCDNDetectionOnce()
|
|
|
|
var buf bytes.Buffer
|
|
var bufMu sync.Mutex
|
|
prev := log.Writer()
|
|
// log.Printf can be called concurrently; serialize writes to buf
|
|
// so we never race the test's own assertion read.
|
|
log.SetOutput(writerFunc(func(p []byte) (int, error) {
|
|
bufMu.Lock()
|
|
defer bufMu.Unlock()
|
|
return buf.Write(p)
|
|
}))
|
|
defer log.SetOutput(prev)
|
|
|
|
var nextCalls int64
|
|
h := cdnDetectionMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt64(&nextCalls, 1)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
const n = 50
|
|
var wg sync.WaitGroup
|
|
wg.Add(n)
|
|
for i := 0; i < n; i++ {
|
|
go func() {
|
|
defer wg.Done()
|
|
req := httptest.NewRequest("GET", "/api/observers", nil)
|
|
req.Header.Set("CF-Ray", "abc123-LAX")
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
if got := atomic.LoadInt64(&nextCalls); got != n {
|
|
t.Fatalf("middleware must call next on every concurrent request; got %d, want %d", got, n)
|
|
}
|
|
|
|
bufMu.Lock()
|
|
out := buf.String()
|
|
bufMu.Unlock()
|
|
got := strings.Count(out, "detected request via CDN")
|
|
if got != 1 {
|
|
t.Errorf("expected sync.Once to admit exactly ONE warning under %d concurrent first-requests; got %d. Output:\n%s", n, got, out)
|
|
}
|
|
}
|
|
|
|
// writerFunc adapts a function to io.Writer.
|
|
type writerFunc func(p []byte) (int, error)
|
|
|
|
func (f writerFunc) Write(p []byte) (int, error) { return f(p) }
|
|
|
|
// Round-2 MAJOR finding: sync.Once only short-circuits the log.Printf,
|
|
// not the per-request header scan. firstCDNHeader still iterates 4
|
|
// http.Header.Get lookups on every /api request after warning fires.
|
|
// The fix is an atomic.Bool fast-path checked BEFORE firstCDNHeader.
|
|
// This test gates that the flag is actually set on the first CDN
|
|
// request — without it, the middleware would have no signal to
|
|
// short-circuit on, and the optimization would be a dead store.
|
|
func TestCDNDetection_CdnWarnedFlagSet(t *testing.T) {
|
|
resetCDNDetectionOnce()
|
|
req := httptest.NewRequest("GET", "/api/x", nil)
|
|
req.Header.Set("CF-Ray", "x")
|
|
if _, nextCalled := runWithCDNMiddleware(t, req); !nextCalled {
|
|
t.Fatal("middleware did not call next handler")
|
|
}
|
|
if !cdnWarned.Load() {
|
|
t.Fatal("cdnWarned must be true after first CDN request (fast-path flag not set)")
|
|
}
|
|
}
|