// ── POST /api/csp-report — Content-Security-Policy violation sink ───────────── // // The target policy ships on Content-Security-Policy-Report-Only for one release // before it is enforced (docs/website/API_V2_PLAN.md § Phase 1). That soak is only // worth anything if the reports land somewhere a human reads, so `report-to` / // `report-uri` point here (see config/csp.js) and this writes them to the `csp` log // tag. It is deliberately same-origin: reports describe attacks against this site // and must not be handed to a third-party collector. // // This is an unauthenticated public POST — browsers send reports with no session and // no CSRF token, and gating it would silence exactly the anonymous visitors whose // pages we most want to hear about. So treat every field as hostile: // • the body is parsed under a small cap (browsers send a few KB), // • the rate limiter blunts a flood, since each accepted report writes a log line, // • every logged field is truncated, and only a fixed allowlist of fields is read. // Nothing is echoed back and nothing is persisted to the database. // // Retiring this: when the tightened policy flips to enforced and the report-only // twin is removed, this endpoint goes with it — unless a `report-to` group is kept // on the enforced policy, which is a reasonable thing to want. const express = require('express') const log = require('../utils/logger')('csp') // Browsers send a few KB at most. A cap this low means a junk POST is rejected by // the parser before any of this code runs. const BODY_LIMIT = '16kb' // Keep log lines bounded: `script-sample` in particular is attacker-influenced and // can carry a whole inline script. const MAX_FIELD = 200 const clip = (value) => { if (value == null) return undefined const s = String(value) return s.length > MAX_FIELD ? `${s.slice(0, MAX_FIELD)}…` : s } /** * Normalize the two wire formats into one shape. * * `report-uri` (Firefox, Safari) POSTs `application/csp-report` with a single * `{ "csp-report": { … } }` object and hyphenated keys. `report-to` (Chrome) POSTs * `application/reports+json` with an *array* of envelopes whose `body` uses camelCase * keys. Reading only one of them would silently drop half the browsers. */ function normalize(body) { if (Array.isArray(body)) { return body .filter((entry) => entry && entry.type === 'csp-violation' && entry.body) .map((entry) => ({ documentUrl: entry.body.documentURL || entry.url, directive: entry.body.effectiveDirective, blockedUrl: entry.body.blockedURL, disposition: entry.body.disposition, sample: entry.body.sample, })) } if (body && typeof body === 'object' && body['csp-report']) { const r = body['csp-report'] return [ { documentUrl: r['document-uri'], directive: r['effective-directive'] || r['violated-directive'], blockedUrl: r['blocked-uri'], disposition: r.disposition, sample: r['script-sample'], }, ] } return [] } // POST /api/csp-report function receive(req, res) { /* #swagger.tags = ['Health'] #swagger.summary = 'Content-Security-Policy violation report sink' #swagger.description = 'Receives CSP violation reports from browsers (both the `report-uri` `application/csp-report` format and the Reporting API `application/reports+json` format). Unauthenticated by necessity — browsers send reports with no session. Reports are logged, never stored or echoed. Always answers 204.' #swagger.security = [] #swagger.responses[204] = { description: 'Report accepted (or ignored). No content.' } #swagger.responses[429] = { description: 'Too many reports from this address.' } */ // 204 regardless of what arrived. A browser cannot act on an error here, and a // non-2xx would only make it retry or log noise in the user's console. for (const v of normalize(req.body)) { if (!v.directive) continue log.warn('csp violation', { // 'report' = the report-only policy fired (expected during the soak); // 'enforce' = the live policy actually blocked something. disposition: clip(v.disposition) || 'report', directive: clip(v.directive), blocked: clip(v.blockedUrl), document: clip(v.documentUrl), sample: clip(v.sample), ip: req.ip, }) } res.status(204).end() } /** * The full middleware chain for the endpoint. Both content types get their own * parser because express.json() only matches `application/json` by default, and a * report that arrives unparsed is a report silently discarded. */ const parsers = [ express.json({ limit: BODY_LIMIT, type: 'application/csp-report' }), express.json({ limit: BODY_LIMIT, type: 'application/reports+json' }), express.json({ limit: BODY_LIMIT }), // Swallow malformed / oversized bodies here rather than letting them reach the // global error handler, which would answer 400 and write an ERROR line quoting the // junk — turning "POST garbage at the open endpoint" into a log-flood primitive. // A browser has nothing useful to do with a 4xx from a report sink anyway. // eslint-disable-next-line no-unused-vars (err, req, res, next) => res.status(204).end(), ] module.exports = { receive, parsers, normalize }