feat(security): soak the tightened CSP on report-only, with a same-origin sink #100

Merged
whitlocktech merged 1 commits from feature/csp-report-only into main 2026-07-27 20:31:33 +00:00
Member

What & why

Phase 1 of docs/website/API_V2_PLAN.md — the tightened CSP ships on Content-Security-Policy-Report-Only alongside 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 in app.js doesn't list it — but the middleware runs with useDefaults: true, and helmet's default set already supplies form-action 'self'. Production has been serving it all along.

Caught by capturing the live Content-Security-Policy header 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.js anyway — 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, and test/csp.test.js pins 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-ancestors violation 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 sink

report-to needs somewhere to point.

  • Same-origin on purpose. Violation reports describe attacks against this site; they don't go to a third-party collector.
  • Outside /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.
  • Both wire formats. report-uri (Firefox, Safari) POSTs hyphenated keys as application/csp-report; report-to (Chrome) POSTs camelCase envelopes as application/reports+json. Handling one silently drops half the browsers. report-to also needs the Reporting-Endpoints response 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:

Guard Why
16 KB body cap Junk is rejected by the parser before any handler code runs
Per-IP rate limit (60 / 5 min) Each accepted report writes a log line
Fixed field allowlist, every field truncated to 200 chars script-sample is attacker-influenced and can carry a whole inline script
Always 204, even for malformed input A 4xx reaches the global error handler, which logs the offending body — that turns an open endpoint into a log-flood primitive

Nothing is persisted; reports land on the csp log tag. Watch that tag for one release, then flip.

Manifest: 199 → 200

The one new URL shows up as a deliberate, reviewed +1 in routes.manifest.json instead 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

cd website/server
npm test                              # 434 tests, 0 fail (9 new)
npm run routes:manifest -- --check    # up to date (202 routes)
npm run swagger                       # +18 lines, only the new path

Live header capture, before vs after, off the running app:

before       …connect-src 'self';frame-ancestors 'self';object-src 'none';base-uri 'self';form-action 'self';script-src-attr 'none'
after        …connect-src 'self';frame-ancestors 'self';object-src 'none';base-uri 'self';form-action 'self';script-src-attr 'none'   <- identical
report-only  ���frame-ancestors 'none';…;report-to csp-endpoint;report-uri /api/csp-report;…

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.js already sets modulePreload: { polyfill: false }.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • No AI tools were used to produce this contribution.
  • AI tools were used. Tool(s): Claude Code (Opus). I have reviewed and understand
    every change, and take responsibility for it. AI-authored commits are
    marked with a Co-Authored-By / Assisted-By trailer.

License

  • I agree that my contribution is licensed under this project's license
    (GNU GPL v3.0 or later), and I have the right to contribute it.
## What & why **Phase 1 of `docs/website/API_V2_PLAN.md`** — the tightened CSP ships on `Content-Security-Policy-Report-Only` **alongside 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 in `app.js` doesn't list it — but the middleware runs with `useDefaults: true`, and helmet's default set already supplies `form-action 'self'`. Production has been serving it all along. Caught by capturing the live `Content-Security-Policy` header 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.js` anyway — 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, and `test/csp.test.js` pins 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-ancestors` violation 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 sink `report-to` needs somewhere to point. - **Same-origin on purpose.** Violation reports describe attacks against this site; they don't go to a third-party collector. - **Outside `/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. - **Both wire formats.** `report-uri` (Firefox, Safari) POSTs hyphenated keys as `application/csp-report`; `report-to` (Chrome) POSTs camelCase envelopes as `application/reports+json`. Handling one silently drops half the browsers. `report-to` also needs the `Reporting-Endpoints` response 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: | Guard | Why | |---|---| | 16 KB body cap | Junk is rejected by the parser before any handler code runs | | Per-IP rate limit (60 / 5 min) | Each accepted report writes a log line | | Fixed field allowlist, every field truncated to 200 chars | `script-sample` is attacker-influenced and can carry a whole inline script | | **Always 204, even for malformed input** | A 4xx reaches the global error handler, which logs the offending body — that turns an open endpoint into a log-flood primitive | Nothing is persisted; reports land on the `csp` log tag. Watch that tag for one release, then flip. ### Manifest: 199 → 200 The one new URL shows up as a **deliberate, reviewed `+1`** in `routes.manifest.json` instead 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 ``` cd website/server npm test # 434 tests, 0 fail (9 new) npm run routes:manifest -- --check # up to date (202 routes) npm run swagger # +18 lines, only the new path ``` Live header capture, before vs after, off the running app: ``` before …connect-src 'self';frame-ancestors 'self';object-src 'none';base-uri 'self';form-action 'self';script-src-attr 'none' after …connect-src 'self';frame-ancestors 'self';object-src 'none';base-uri 'self';form-action 'self';script-src-attr 'none' <- identical report-only ���frame-ancestors 'none';…;report-to csp-endpoint;report-uri /api/csp-report;… ``` 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.js` already sets `modulePreload: { polyfill: false }`. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [ ] No AI tools were used to produce this contribution. - [x] AI tools were used. Tool(s): `Claude Code (Opus)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` / `Assisted-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it.
wtclaude added 1 commit 2026-07-27 20:21:34 +00:00
feat(security): soak the tightened CSP on report-only, with a same-origin sink
All checks were successful
PR Checks / bot-install (pull_request) Successful in 16s
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 9m15s
9b74999610
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>
whitlocktech approved these changes 2026-07-27 20:22:58 +00:00
whitlocktech scheduled this pull request to auto merge when all checks succeed 2026-07-27 20:23:02 +00:00
whitlocktech merged commit 0dc5af0d8b into main 2026-07-27 20:31:33 +00:00
whitlocktech deleted branch feature/csp-report-only 2026-07-27 20:31:34 +00:00
Sign in to join this conversation.
No description provided.