// ── Content-Security-Policy ──────────────────────────────────────────────── // // Two policies ship at once, on two different headers: // // Content-Security-Policy → `enforced` (today's policy, unchanged) // Content-Security-Policy-Report-Only → `reportOnly` (the target, + a report sink) // // Report-only first, one release of observation, then the two collapse into one // enforced policy (docs/website/API_V2_PLAN.md § Phase 1). Shipping the tightened // policy straight to `Content-Security-Policy` would mean discovering any legitimate // use we forgot as a broken page in production; shipping it *alongside* the current // one means a violation report instead, with the live policy still protecting users // the whole time. // // Notes on each non-'self' allowance in the base policy: // • style-src 'unsafe-inline' — React renders pervasive inline `style={{…}}` // attributes, and CSP style *attributes* cannot be nonce'd; this is required. // It permits inline styling, not script execution. Also whitelists the Google // Fonts stylesheet host. // • font-src — Google Fonts (Cinzel) serves the font files from gstatic. // • img-src https:/data: — uploaded images are same-origin, but wiki/news bodies // (sanitizeHtml allows over http/https) and BRAND_* logo/hero/favicon may // point at external https images. http images are blocked by mixed-content on // the https site anyway. // • connect-src 'self' — the REST API and SSE streams are same-origin. This is the // exfiltration channel; do not widen it unless the API genuinely becomes // cross-origin (which would also reopen the auth-merge question — see the plan). // • script-src 'self' with no 'unsafe-inline'/'unsafe-eval' is the primary defense. // Vite is configured with `modulePreload: { polyfill: false }` (client/vite.config.js) // precisely so the build emits no inline bootstrap script for this to trip on. // • upgrade-insecure-requests is intentionally dropped: TLS is terminated at the // proxy, there are no mixed-content subresources to upgrade, and leaving it on // breaks a local `npm start` served over plain http. // // The interactive API docs at /api/docs get their own looser policy (swagger-ui // injects an inline bootstrap script); that carve-out lives in app.js and stays // scoped to the one route. // Where violation reports are POSTed, and the Reporting-API group name that points // at it. Same-origin on purpose — reports describe attacks against this site and // must not be shipped to a third party. const REPORT_PATH = '/api/csp-report' const REPORT_GROUP = 'csp-endpoint' // The policy in force today. Behaviourally unchanged by this phase — it is the safety // net while the tightened twin is only being observed. const enforced = { 'default-src': ["'self'"], 'script-src': ["'self'"], 'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], 'font-src': ["'self'", 'https://fonts.gstatic.com'], 'img-src': ["'self'", 'data:', 'https:'], 'connect-src': ["'self'"], 'frame-ancestors': ["'self'"], 'object-src': ["'none'"], 'base-uri': ["'self'"], // Blocks an injected `
` from POSTing credentials // off-origin — an exfil path connect-src does not cover. Already emitted today via // helmet's `useDefaults`, and pinned here on purpose: a security directive should // not depend on a third-party library's default surviving its next major version. // Adding it changes the header's *contents* not at all. 'form-action': ["'self'"], 'upgrade-insecure-requests': null, } // The one directive this phase actually changes, and so the only thing a report can // legitimately be about: // // • frame-ancestors 'self' → 'none'. Nothing legitimately frames the site, and // 'self' only means anything if some same-origin page frames another; none does. // Worth a soak rather than a straight flip precisely because a violation report // is how we would find out that something *does* — the report comes from the // browser of whoever framed us, which is information we cannot get any other way. // // Derived from `enforced` rather than written out again, so the two policies cannot // silently drift apart and this object stays a readable diff of the change. const tightened = { ...enforced, 'frame-ancestors': ["'none'"], } const reportOnly = { ...tightened, // Both mechanisms, deliberately: `report-to` is the current Reporting API (Chrome, // needs the Reporting-Endpoints header below), `report-uri` is deprecated but is // still the only one Firefox and Safari implement. Browsers that support both send // one report, not two. 'report-to': [REPORT_GROUP], 'report-uri': [REPORT_PATH], } /** * Names the Reporting-API group that `report-to` refers to. Without this header the * `report-to` directive is inert, and only the `report-uri` fallback would fire. */ function reportingEndpoints(req, res, next) { res.setHeader('Reporting-Endpoints', `${REPORT_GROUP}="${REPORT_PATH}"`) next() } module.exports = { enforced, tightened, reportOnly, reportingEndpoints, REPORT_PATH, REPORT_GROUP }