feat(security): soak the tightened CSP on report-only, with a same-origin sink #100
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/csp-report-only"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What & why
Phase 1 of
docs/website/API_V2_PLAN.md— the tightened CSP ships onContent-Security-Policy-Report-Onlyalongside the unchanged enforced policy for one release. A follow-up PR flips it once the soak comes back clean.The plan said two directives. It is one.
The plan listed "add
form-action 'self'(currently absent)" as half the delta. It was not absent. The directives object inapp.jsdoesn't list it — but the middleware runs withuseDefaults: true, and helmet's default set already suppliesform-action 'self'. Production has been serving it all along.Caught by capturing the live
Content-Security-Policyheader off the running app rather than reading the config, which is how the plan got it wrong in the first place.It is now written out explicitly in
config/csp.jsanyway — a security directive shouldn't depend on a third-party library's default surviving its next major version. The header's contents don't change by a byte, andtest/csp.test.jspins the enforced header verbatim so a future edit can't quietly alter what users are actually protected by.So the entire behavioural delta of this phase is
frame-ancestors 'self'→'none'.That's still the directive most worth soaking, and arguably the only one where a soak tells you something you cannot get any other way: a
frame-ancestorsviolation is reported by the browser of whoever framed the site. It is the only mechanism that can reveal a legitimate embed before an enforcing policy breaks it.POST /api/csp-report— the sinkreport-toneeds somewhere to point./api/v1, next to/api/health. The browser learns the path from the policy header, never from a client build — it isn't versioned client contract.report-uri(Firefox, Safari) POSTs hyphenated keys asapplication/csp-report;report-to(Chrome) POSTs camelCase envelopes asapplication/reports+json. Handling one silently drops half the browsers.report-toalso needs theReporting-Endpointsresponse header or it is inert — a soak that half-works while looking fine is the real failure mode here, so there's a test for it.It is necessarily unauthenticated — browsers send reports with no session, and gating it would silence exactly the anonymous visitors worth hearing about. So it is bounded on every axis:
script-sampleis attacker-influenced and can carry a whole inline scriptNothing is persisted; reports land on the
csplog tag. Watch that tag for one release, then flip.Manifest: 199 → 200
The one new URL shows up as a deliberate, reviewed
+1inroutes.manifest.jsoninstead of slipping through — PR 0's freeze doing exactly its job on its first real exercise. Swagger regenerated to match.Docs companion: RunicGateway/docs#51.
How it was tested
Live header capture, before vs after, off the running app:
Sink exercised by hand and in tests with: both content types, malformed JSON, a 40 KB oversized body, an empty body, and
GET(404 — POST-only). Every POST case answers 204 and nothing reaches the global error handler.The plan's prerequisite — Vite's inline modulepreload polyfill tripping
script-src 'self'— needed no work:client/vite.config.jsalready setsmodulePreload: { polyfill: false }.Checklist
AI-assisted contributions (required)
Claude Code (Opus). I have reviewed and understandevery change, and take responsibility for it. AI-authored commits are
marked with a
Co-Authored-By/Assisted-Bytrailer.License
(GNU GPL v3.0 or later), and I have the right to contribute it.
Phase 1 of docs/website/API_V2_PLAN.md. The tightened policy ships on Content-Security-Policy-Report-Only alongside the unchanged enforced one for a release; a follow-up PR flips it after the soak comes back clean. The plan expected a two-directive delta. It is one. `form-action 'self'` was described as absent because it is not in the directives object in app.js — but the middleware runs with `useDefaults: true` and helmet's defaults already supply it, so the header served in production has carried it all along. Caught by capturing the live header from the running app instead of reading the config. It is now written out explicitly in config/csp.js regardless: a security directive should not depend on a third-party library's default surviving its next major version. The enforced header's contents do not change at all, and a test pins it verbatim. So the whole behavioural delta is `frame-ancestors 'self'` -> `'none'`. That is still the directive most worth soaking: a frame-ancestors report is generated by the browser of whoever framed the site, which is the only way to find out that something legitimately embeds us before an enforcing policy breaks it. The policies move to config/csp.js, with the report-only one derived by spread from the enforced one so the two cannot drift and the object reads as a diff. `report-to` needs somewhere to point, so this adds POST /api/csp-report -- same-origin on purpose, since reports describe attacks against this site and should not go to a third-party collector. It is mounted outside /api/v1 next to /api/health: the browser learns the path from the policy header, never from a client build, so it is not versioned client contract. It is necessarily unauthenticated -- browsers send reports with no session, and gating it would silence exactly the anonymous visitors worth hearing about -- so it is bounded on every axis: * both wire formats, since report-uri (Firefox/Safari) sends hyphenated keys in application/csp-report and report-to (Chrome) sends camelCase envelopes in application/reports+json; handling one silently drops half the browsers, * report-to also needs the Reporting-Endpoints response header or it is inert, * 16 KB body cap, per-IP rate limit, fixed field allowlist, every logged field truncated (script-sample is attacker-influenced and can carry a whole inline script), * always 204, even for malformed input: a 4xx would reach the global error handler, which logs the offending body -- turning an open endpoint into a log-flood primitive. Nothing is persisted; reports go to the `csp` log tag. routes.manifest.json moves 199 -> 200, which is the freeze from PR 0 working as designed: the one new URL is visible as a reviewed +1 rather than slipping through. Swagger regenerated to match. Co-Authored-By: Claude <noreply@anthropic.com>