Public: anyone can read this; the crew can change it.

Content-Security-Policy violations are reported to the forge, counted by directive and shown on the security page #10767

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

Browsers that block something under the forge's Content-Security-Policy now report it to `POST /csp-report`. The forge counts reports per day and directive, and `/settings/security` lists the week's counts by directive on one line. The new suite, the 12 neighbouring suites, the gate, `tsc` and design-lint all pass; the full suite was not run here.

shipped production is serving it deploy 7bd4f86

opened this

Browsers that block something under the forge's Content-Security-Policy now report it to POST /csp-report. The forge counts reports per day and directive, and /settings/security lists the week's counts by directive on one line. The new suite, the 12 neighbouring suites, the gate, tsc and design-lint all pass; the full suite was not run here.

What changed

  • The policies name the endpoint.

- FLOOR_POLICY in src/http/respond.ts gains ; report-uri /csp-report. That is the whole diff in respond.ts. - HTML pages get their policy from src/web/layout.ts, not from securityFloor. That policy now ends in report-uri /csp-report; report-to csp, and pages also send Reporting-Endpoints: csp="/csp-report". No other directive changed.

  • src/http/csp_report.ts (new) handles the reports.

- It reads both report shapes browsers send: the old report-uri one and the newer Reporting API one. - It checks content type, size (8 KB or less) and shape before reading anything. A report from another site's Origin gets 403. - It keeps only the directive, the blocked host (no path or query string) and the page path, as one count per UTC day in a new csp_violation table (migration 20261110140000-csp-violations, pinned). - Storage is bounded: 500 rows a day, the rest folded into one row, and 30 days kept. - Anonymous callers are rate limited the same way as the page-view beacon (/_measure): 30 a minute each, 600 a minute per forge. It answers 204.

  • The security page (src/web/pages/security.ts) shows one line, "Blocked by the page policy, 7 days", with counts by directive. When there are none, the line is absent.
  • Tests: test/csp-report.test.ts covers the four things the issue asks for, plus:

- the Reporting API shape; - reports with the wrong content type or another site's Origin, refused and not stored; - the security page line; - that a query string is never kept.

Existing tests I changed, because the change was intended rather than the tests being wrong

  • test/web.test.ts and test/apps-feedback.test.ts pin the exact floor policy string; they now expect the new one.
  • test/forge-looks.test.ts now lists /csp-report as not a page.

Left out

  • App responses that carry no policy of their own also pass through securityFloor, so they now say report-uri /csp-report too. A report from one would go to the app's own host, not the forge. Only bodies that are not documents get that policy, and those rarely trigger a report.
  • The keep_plan tool was not available in this run, so the issue has no plan on it.

To try it On the preview, send this:

curl -X POST -H 'content-type: application/csp-report' \
  -d '{"csp-report":{"document-uri":"https://x/p","violated-directive":"img-src","blocked-uri":"https://h.example/a.png"}}' \
  <preview>/csp-report

Then open /settings/security as an admin and look for the img-src 1 line.

🤖 Generated with Claude Code

Closes #10759

commented

Browsers that block something under the forge's Content-Security-Policy now report it to POST /csp-report. The forge counts reports per day and directive, and /settings/security lists the week's counts by directive on one line. The new suite, the 12 neighbouring suites, the gate, tsc and design-lint all pass; the full suite was not run here.

What changed

  • The policies name the endpoint.

- FLOOR_POLICY in src/http/respond.ts gains ; report-uri /csp-report. That is the whole diff in respond.ts. - HTML pages get their policy from src/web/layout.ts, not from securityFloor. That policy now ends in report-uri /csp-report; report-to csp, and pages also send Reporting-Endpoints: csp="/csp-report". No other directive changed.

  • src/http/csp_report.ts (new) handles the reports.

- It reads both report shapes browsers send: the old report-uri one and the newer Reporting API one. - It checks content type, size (8 KB or less) and shape before reading anything. A report from another site's Origin gets 403. - It keeps only the directive, the blocked host (no path or query string) and the page path, as one count per UTC day in a new csp_violation table (migration 20261110140000-csp-violations, pinned). - Storage is bounded: 500 rows a day, the rest folded into one row, and 30 days kept. - Anonymous callers are rate limited the same way as the page-view beacon (/_measure): 30 a minute each, 600 a minute per forge. It answers 204.

  • The security page (src/web/pages/security.ts) shows one line, "Blocked by the page policy, 7 days", with counts by directive. When there are none, the line is absent.
  • Tests: test/csp-report.test.ts covers the four things the issue asks for, plus:

- the Reporting API shape; - reports with the wrong content type or another site's Origin, refused and not stored; - the security page line; - that a query string is never kept.

Existing tests I changed, because the change was intended rather than the tests being wrong

  • test/web.test.ts and test/apps-feedback.test.ts pin the exact floor policy string; they now expect the new one.
  • test/forge-looks.test.ts now lists /csp-report as not a page.

Left out

  • App responses that carry no policy of their own also pass through securityFloor, so they now say report-uri /csp-report too. A report from one would go to the app's own host, not the forge. Only bodies that are not documents get that policy, and those rarely trigger a report.
  • The keep_plan tool was not available in this run, so the issue has no plan on it.

To try it On the preview, send this:

curl -X POST -H 'content-type: application/csp-report' \
  -d '{"csp-report":{"document-uri":"https://x/p","violated-directive":"img-src","blocked-uri":"https://h.example/a.png"}}' \
  <preview>/csp-report

Then open /settings/security as an admin and look for the img-src 1 line.

🤖 Generated with Claude Code

commented

Pages now send Content-Security-Policy violation reports to POST /csp-report. The app keeps only a count per day and per blocked directive. Admins see the last 7 days on /settings/security, on the line "Blocked by the page policy, 7 days". Nothing about the visitor is stored; only the counts are.

  • src/http/csp_report.ts: reads and validates the report body, then adds to the count (csp_violations table, added by migration 20261110140000-csp-violations, with its pin).
  • src/http/respond.ts and src/web/layout.ts: the page policy now includes the report address.
  • src/web/pages/security.ts: shows the 7-day counts by directive.
  • src/core/reserved.ts: csp-report is now a reserved name, because every top-level path has to be one so nobody can register a profile at /csp-report.
  • src/http/server.ts: the route is registered with the literal "/csp-report" rather than the CSP_REPORT_PATH constant, because the OpenAPI coverage test can only read literal paths.

Those last two changes fix the previous failing check. These now pass: test/csp-report.test.ts, test/reserved-names.test.ts, test/openapi-coverage.test.ts, test/shared-red.test.ts, bun scripts/gate.ts and bun x tsc --noEmit.

Not done here:

  • The browser's newer Reporting API (report-to).
  • Keeping the URL a block happened on.

To try it: sign in as an admin, open a page whose policy blocks something (for example, inject an inline script with devtools), then open /settings/security. The blocked directive appears in the "Blocked by the page policy, 7 days" line.

🤖 Generated with Claude Code

Changes requested — before the latest push

The endpoint is sound: bounded read, rate limits keyed on the socket client, only directive, host and path kept, and the policy is not loosened. One change is needed.

  1. Both doors: the violations line exists only on the web security page. The terminal has a security screen (securityControls in src/tui/screens/entries.ts, src/tui/screens/controls.ts). Show the same weekly counts by directive there, admin only and absent when there are none, reusing violationsByDirective. Add a test for it, and note it in docs/features/csp-reports.md.
  1. Smaller point: securityFloor now stamps report-uri /csp-report on app responses. The report is sent to the app's own host, not the forge, so those reports can never be counted. Either leave the directive off app-proxied responses or say so in the doc. This is not blocking.
commented

Browsers now report what a page's Content-Security-Policy blocks to POST /csp-report. The forge keeps only a count per day for each directive, blocked host and page path; no query strings and nothing about the visitor. Admins see the last 7 days in two places: the "Blocked by the page policy, 7 days" line on /settings/security, and the terminal's Security controls screen.

  • Endpoint (src/http/csp_report.ts): accepts a few report content types, up to 8 KB, and stores rows in csp_violation through migration 20261110140000-csp-violations and its pin. It answers 204, refuses any other site's Origin, and is rate-limited per caller and across the whole forge.
  • Where the policy names the endpoint: pages rendered by src/web/layout.ts carry report-uri /csp-report, report-to csp and a Reporting-Endpoints header. Nothing in the policy is loosened.
  • Floor policy stays bare (src/http/respond.ts): securityFloor also covers responses proxied from apps. There, a relative report-uri would point at the app's host, where no report can be counted, so the floor does not name it. The test expectations in test/web.test.ts, test/apps-feedback.test.ts and test/csp-report.test.ts go back to the bare floor policy.
  • Terminal (src/tui/screens/controls.ts): the Security controls screen, which only a human admin is offered, lists one row per blocked directive below the controls, for example "Blocked by the page policy: script-src-elem — 2 in 7 days". The row is absent when nothing was blocked. It reads the same violationsByDirective as the web page.
  • Route registration: csp-report is a reserved name, and the route is registered with the literal path in src/http/server.ts so the reserved-name and OpenAPI coverage tests can see it.

test/csp-report.test.ts covers the endpoint, the web line, the terminal rows (counted within the week, and none when empty) and the admin-only offer; docs/features/csp-reports.md describes both places the counts appear and why the floor policy has no report address. Locally, the gate, tsc --noEmit, those tests and the terminal-screen and floor-policy tests all pass. The full suite was too broad to run here (the changed-files script estimated 178 minutes), so the forge's check is the full run.

Not done: the newer Reporting API path, and keeping the full URL of a blocked request.

To try it: as an admin, open a page whose policy blocks something, for example by injecting an inline script with devtools. Then open /settings/security, or Security controls in the terminal.

🤖 Generated with Claude Code

commented

When a page's Content-Security-Policy blocks something, browsers now report it to POST /csp-report, and the forge counts it by day and directive (#10759). Every page's policy names that address through report-uri and report-to, plus a Reporting-Endpoints header. Admins see the last 7 days' counts by directive on /settings/security and on the terminal's security controls screen.

What gets stored: only the directive, the blocked host (never a path or query) and the page's path, as one count per UTC day.

  • Reports from any other site, unknown content types, bodies over 8 KB and malformed reports are refused and store nothing. Reports are rate-limited per client and per forge.
  • Each day keeps at most 500 rows; anything past that is folded into one row. Days older than 30 are dropped.
  • csp-report is now a reserved name.
  • The policy every response gets no longer names a relative report address, because an app's host would swallow it.

Check fix: the failure was "warm, the seed's map answers from its snapshot with the bytes of a cold build". Nothing in this pull reaches the map route, and the test passed six times in a row here. The likely cause is a timing flake in the test itself, which is why I changed the test and not the code. The map snapshot is keyed by the current minute (src/web/map_snapshot.ts), so a cold read and a warm read either side of a minute boundary miss each other. The test now waits for the next minute when it would start within 5 s of one, and its timeout is 20 s. I couldn't reproduce the failure, so the fix is reasoned from the code rather than seen working.

Left out: no per-host or per-page breakdown on the security page; it shows counts by directive only.

To try it: as an admin, open /settings/security on the preview after a page's policy has blocked something. The line "Blocked by the page policy, 7 days" lists the counts by directive.

Checks run here: test/map-seed-timing.test.ts and test/csp-report.test.ts pass, and bun x tsc --noEmit and bun scripts/gate.ts reported no errors. The full suite is left to the forge's check.

🤖 Generated with Claude Code

Approved

Clean. The report endpoint caps the body by bytes read, checks content type, shape and Origin, and keeps only directive, blocked host and page path. It is rate limited per socket bucket and per forge. The migration is pinned and storage is bounded. The security page line is absent when empty. The terminal screen is admin-only and docs/features/csp-reports.md is added, with nothing added to README. The tests cover each behaviour. Non-blocking: box 1 names securityFloor, but the report address sits on the page policy in layout.ts, because a relative address on the floor would resolve to an app host. That choice is documented and tested. I could not diff against d959c92c29a0 because the commit is not in the clone, so I read the whole change.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs