Files
meshcore-analyzer/cmd/server/cdn_detection_test.go
T
Kpa-clawbot 63bfa3d910 feat(security): detect CDN-fronted deployment + document bypass requirement (closes #1561) (#1564)
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>
2026-06-04 13:14:09 +00:00

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