From 9a3e1cc1e7e40bb042f6a4a02311474b634003c9 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 27 Jul 2026 15:19:43 -0500 Subject: [PATCH] docs(website): correct the CSP delta to one directive and document the report sink Companion to website "feat(security): soak the tightened CSP on report-only". The plan's Phase 1 claimed a two-directive delta, one of which was adding `form-action 'self'` as "currently absent". It was not absent. The directives object in app.js does not list it, but the middleware runs `useDefaults: true` and helmet's default set already supplies it, so production has been serving it all along. The plan was written from the config rather than from the live header; the correction, and how it was caught, are now recorded in place rather than quietly fixed. That leaves `frame-ancestors 'self'` -> `'none'` as the entire behavioural delta of the phase. Worth noting that this is also the directive that most justifies a soak: a frame-ancestors violation is reported by the browser of whoever framed the site, so it is the only available way to discover a legitimate embed before an enforcing policy breaks it. Also documented: * POST /api/csp-report -- the same-origin sink report-to/report-uri point at, why it is same-origin, why it lives outside /api/v1, both wire formats, the Reporting-Endpoints header requirement, and the properties that make an unauthenticated public POST safe (always-204, caps, truncation, rate limit). * That the sink is scoped to the soak, so the enforce PR must decide explicitly whether to retire it or keep a report-to group on the enforced policy -- rather than leaving an orphan route behind. * The `[csp]` log tag in section 7.5 as the thing to watch during the soak, and what silence across one release means. * client/vite.config.js already sets `modulePreload: { polyfill: false }`, so the plan's inline-polyfill prerequisite was already satisfied. api-route-inventory.json moves 199 -> 200 for the new route. That is the PR 0 freeze working as intended: the first manifest diff since the baseline is a deliberate, reviewed one. Co-Authored-By: Claude --- website/API_V2_PLAN.md | 71 ++++++++++++++++++++++++++------ website/BACKEND_DESIGN.md | 35 ++++++++++++++-- website/api-route-inventory.json | 4 ++ 3 files changed, 94 insertions(+), 16 deletions(-) diff --git a/website/API_V2_PLAN.md b/website/API_V2_PLAN.md index 0a1b4a3..03557a3 100644 --- a/website/API_V2_PLAN.md +++ b/website/API_V2_PLAN.md @@ -181,22 +181,64 @@ form-action 'self'; frame-ancestors 'none'; ``` -Delta vs. the policy in `server/src/app.js` today — the whole change is two directives: +Delta vs. the policy in `server/src/app.js` today. This was written as two directives; on +implementation it turned out to be **one**: -- **Add `form-action 'self'`** (currently absent) — blocks an injected `
` - from POSTing credentials off-origin, an exfil path `connect-src` doesn't cover. -- **Tighten `frame-ancestors`** `'self'` → `'none'` — nothing legitimately frames the site. +- ~~**Add `form-action 'self'`** (currently absent)~~ — **it was not absent.** The directives object in + `app.js` does not list it, but the middleware is configured `useDefaults: true`, and helmet's default + set already supplies `form-action 'self'` — so the header served in production has carried it all + along. Verified by capturing the live `Content-Security-Policy` header from the running app rather + than reading the config, which is how the plan got this wrong. **No behavioural change here.** It is + now written out explicitly in `config/csp.js` anyway: a security directive should not depend on a + third-party library's defaults surviving its next major version. +- **Tighten `frame-ancestors`** `'self'` → `'none'` — nothing legitimately frames the site. **This is + the entire behavioural delta of the phase.** - Unchanged: `default-src`, `script-src`, `connect-src`, `object-src 'none'`, `base-uri 'self'`, and `img-src … https:` (external `BRAND_*` logo/hero and `` in sanitized wiki/news bodies rely on `https:`). +The soak is still worth running for that one directive, and arguably it is the directive that most +needs one: a `frame-ancestors` report is generated by the browser of *whoever framed the site*, so it +is the only way to discover that something legitimately embeds us before the enforcing policy breaks +it. Nothing else can tell us that. + **Rollout:** ship via `Content-Security-Policy-Report-Only` with `report-to` for one release, watch for violations, then flip to enforce. Before trusting `script-src 'self'`: Vite's build injects an inline modulepreload-polyfill `