Skip to main content

CSP violation collection

Why this exists

FE-361 shipped a Report-Only Content Security Policy with no report-to, no report-uri, no Reporting-Endpoints header and no receiving endpoint. Report-Only blocks nothing by design, so the policy delivered neither protection nor evidence: violations rendered in each user's own console and were never aggregated. The only evidence for enforcing was one sweep of 15 routes on one machine.

M33-013 enforces the policy. It cannot do that on evidence until evidence exists, which is this.

⚠⚠ The constraint that shapes everything

A CSP report contains document-uri, and this application puts patient ids in URLs/patients/{patientId}/allergies, /encounters/{encounterId}. Storing raw reports would create a new PHI store: one nobody has classified, outside the M33-006 registry and the M33-007 encryption work, filled by an endpoint that by construction cannot authenticate its caller.

Two consequences, both non-negotiable:

  1. Identifiers are stripped at ingest, before the row is written. The stored value is a route pattern (/patients/:id/allergies), never the concrete URI, and the same applies to referrer. ⚠ Reduce, never redact later — a redaction job leaves a window in which the raw value was on disk and in the write-ahead log, and that window is the whole problem.
  2. Reports never go to a third-party collector. report-uri.com and Sentry are the obvious shortcuts and both would be PHI egress to a processor with no BAA. This is self-hosted.

How the reduction works

CspUriReducer is deny-by-default: every path segment is replaced with :id unless it is recognisably a fixed route word — letters and dashes only, no digits, at most 24 characters.

⚠ The no-digit rule is stricter than it first looks, deliberately. Allowing digits would keep every short identifier this platform uses: a UHP_ID like uhp-000123 starts with a letter and is ten characters, so a length-only rule would store it intact. Numeric concept ids (/concepts/5089) go the same way. Losing the occasional legitimate route word containing a digit costs a little grouping precision; keeping one identifier costs a PHI store.

Also reduced or dropped:

FieldTreatment
document-uri, referrerroute pattern; query string and fragment dropped entirely (a patient-search box puts a name in the query)
blocked-uriorigin only — a blocked third-party script's query string can carry whatever the page passed it. CSP keywords (inline, eval, self, data) are preserved, because "an inline script was blocked" is the most useful thing this reports
script-sample, original-policynot modelled at all. A script sample is a slice of the page's own source; on a page rendering a patient record that is PHI
unparseable URLunknown — never the raw value as a fallback, since that is precisely the URL whose contents are unknown

The endpoint

POST /api/v1/security/csp-reportsanonymous by necessity, not by oversight. Browsers send violation reports as credential-less fire-and-forget beacons. Requiring a session would not secure this; it would stop the reports arriving, silently.

So it is a public write endpoint on a system holding patient data, and every guard matters:

  • the M33-002 per-IP rate limit (its literal is in RateLimitProperties.DEFAULT_PATHS);
  • only application/csp-report, application/reports+json and application/json are accepted;
  • a strict schema — an unrecognised or directive-less report is dropped without persisting;
  • Reporting API batches are capped at 50 entries;
  • nothing is ever echoed back: the response is an empty 204 whatever happened, so it cannot be used as a reflector and cannot report whether a payload was understood.

⚠ Both wire formats are accepted. report-uri (kebab-case, {"csp-report": …}) is deprecated and is still what Safari and older Chromium send; the Reporting API sends camelCase. A collector that silently misses two engines is worse than none, because the gap reads as evidence of absence.

Aggregation

One row per distinct problem, keyed on (effective_directive, blocked_uri_origin, document_uri_pattern), with first_seen_at, last_seen_at and a counter.

⚠ A misconfigured policy on a busy page emits one report per violation per page load. Worse, browser extensions, ISP script injection and malware generate large volumes of spurious violations about pages that are perfectly fine. That is the well-known reason naive CSP collectors become unusable within days.

⚠ The counter is incremented by a single atomic UPDATE, not read-modify-write: a page load emits many identical reports at once, and load-then-save would lose increments under exactly the concurrency this is built to survive. Two reports racing to create the same group is the normal case, not an exception — the unique index makes the loser knowable and it retries as an increment.

Triage

GET /api/v1/security/csp-violations and PATCH /api/v1/security/csp-violations/{id}, gated on security.csp-violation.read / .write. Unlike ingest, reading and triaging is administration.

StateMeaning
NEWseen, not yet triaged
EXPECTEDthe enforcing policy should permit this — the directive needs widening
MUST_FIXthe application is doing something it should not
IGNOREDextension noise, not the application

M33-013's exit condition is "zero NEW and zero MUST_FIX over a representative window" — a decidable criterion rather than a judgement call made at enforcement time. Moving a group to EXPECTED records who decided and when, because M33-013 will enforce a policy on the strength of it.

Do not alert on volume until a fortnight of baseline exists. Extension noise dominates early and will train everyone to ignore the alert.

Retention

CspViolationPurgeJob retires triaged, quiet groups nightly (UHP_SECURITY_CSP_RETENTION_DAYS, default 90). ⚠ It purges only IGNORED and EXPECTED. NEW and MUST_FIX are the states M33-013's exit condition is counted from, so deleting them would make that condition read as satisfied precisely because the evidence had been thrown away.

What FE-368 still owes

The frontend must add report-to and report-uri to its CSP plus the Reporting-Endpoints response header, pointing at this endpoint, and build the triage screen on route key admin.security.csp-violations (already declared backend-side). Until that lands, this collector receives nothing — the backend half cannot make a browser send reports.