Trust demo-code addresses only from a configured proxy (A4-02)

Merged
BI
1d ago
+391-300

Why

The front door keyed the demo-code try limit on X-Real-IP, or the last X-Forwarded-For hop, without checking that a trusted proxy had set them (audit A4-02). A caller could rotate those headers and dodge the per-address limit.

The backend already refuses this: clientAddress in server.mjs reads those headers only from a listed trusted proxy, or when WARD_TRUSTED_PROXY_ADDRESSES=* . This PR brings the Next app in line. CSRF / forwarded-host is left to #1601; src/lib/api-csrf.ts is not touched.

What changes

edgeAddress now mirrors the backend rule once a trusted proxy is configured. With a configured proxy list that does not match the caller, a caller-sent header cannot open a new allowance.

Set WARD_TRUSTED_PROXY_ADDRESSES on the Next / Railway service (the backend already has it, today as *) only after confirming that Railway overwrites a caller-sent X-Real-IP. That confirmation still needs Josh's yes; this PR does not call live Railway.

Safe fallback while the variable is unset (D-147)

Josh ruled on 11 November 2026 ("Add a safe fallback, then merge", recorded as D-147 in docs/ward-flow/decisions.md). The Next session route has no socket address, so the strict rule alone would put every caller in one "unknown" bucket, and ten wrong tries from anyone would refuse every presenter. So while WARD_TRUSTED_PROXY_ADDRESSES names no proxy, edgeAddress keeps main's behaviour: X-Real-IP, else the last X-Forwarded-For hop. Once it is set, the trusted-proxy rule above applies. The fallback logs nothing. No Railway or environment change.

Tests added: per-address keying with the variable unset (one address limited, another still gets its own budget and the right code opens), and the shared bucket when a proxy is listed but the caller is not it.

Tests (local, synthetic only)

  • tests/front-door-pass.test.ts, tests/ward-audit-demo-attempt-reservation.test.ts, tests/decision-log-integrity.test.ts, doc-facts and owner-rulings index tests passed; typecheck passed.

No Cursor cloud, no live Supabase, Railway or production.

šŸ¤– Generated with Claude Code

https://claude.ai/code/session_01J352nhYDmTvC457X3TjY2j