diff --git a/server/routes.guards.json b/server/routes.guards.json index 5d91215..35318cf 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -7,6 +7,14 @@ "handlers": 1, "gates": [] }, + { + "method": "POST", + "path": "/api/csp-report", + "handlers": 6, + "gates": [ + "jsonParser" + ] + }, { "method": "GET", "path": "/api/docs.json", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 7c0db62..63a0207 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -5,6 +5,10 @@ "method": "GET", "path": "/.well-known/assetlinks.json" }, + { + "method": "POST", + "path": "/api/csp-report" + }, { "method": "GET", "path": "/api/docs.json" diff --git a/server/src/app.js b/server/src/app.js index 38ded53..211f240 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -11,7 +11,10 @@ const swaggerUi = require('swagger-ui-express') const apiRouter = require('./router/api.router') const wellKnown = require('./router/wellKnown.controller') +const cspReport = require('./router/cspReport.controller') const brand = require('./config/brand') +const csp = require('./config/csp') +const { cspReportLimiter } = require('./middleware/rateLimit') const createLogger = require('./utils/logger') const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy') const botScore = require('./middleware/botScore') @@ -37,41 +40,31 @@ app.use(trustProxyDebug) app.use(botScore.guard) // Security headers, including a Content-Security-Policy tuned for the built React -// SPA. Notes on each non-'self' allowance: -// • style-src 'unsafe-inline' — React renders pervasive inline `style={{…}}` -// attributes, and CSP style *attributes* cannot be nonce'd; this is required. -// 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. -// • 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 below. +// SPA. The policies themselves (and the reasoning behind every non-'self' allowance) +// live in config/csp.js. The interactive API docs at /api/docs get their own looser +// policy below. app.use( helmet({ - contentSecurityPolicy: { - useDefaults: true, - directives: { - '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'"], - 'upgrade-insecure-requests': null, - }, - }, + contentSecurityPolicy: { useDefaults: true, directives: csp.enforced }, crossOriginResourcePolicy: { policy: 'cross-origin' }, }), ) +// The tightened policy rides alongside on Content-Security-Policy-Report-Only for one +// release, then replaces the enforced one (docs/website/API_V2_PLAN.md § Phase 1). +// Both headers are served at once on purpose: the live policy keeps protecting users +// while anything the tightened version would have broken shows up as a report at +// /api/csp-report instead of as a broken page. Reports are same-origin — they +// describe attacks on this site and are not handed to a third party. +app.use(csp.reportingEndpoints) +app.use( + helmet.contentSecurityPolicy({ + useDefaults: true, + reportOnly: true, + directives: csp.reportOnly, + }), +) + // CORS only when a separate client origin is configured (local Vite dev). In // production the SPA is same-origin, so no CORS is needed. if (process.env.CLIENT_ORIGIN) { @@ -179,6 +172,11 @@ app.get( /* #swagger.responses[200] = { description: 'Service is up', content: { "application/json": { schema: { type: "object", properties: { status: { type: "string", example: "ok" } } } } } } */ (req, res) => res.json({ status: 'ok' }), ) +// CSP violation sink. Mounted here, ahead of the /api 404, and outside /api/v1: it is +// not part of the versioned client contract — it exists for the browser, which learns +// the path from the policy header, never from a client build. +app.post(csp.REPORT_PATH, cspReportLimiter, ...cspReport.parsers, cspReport.receive) + app.use('/api', apiRouter) app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' })) diff --git a/server/src/config/csp.js b/server/src/config/csp.js new file mode 100644 index 0000000..6db8e61 --- /dev/null +++ b/server/src/config/csp.js @@ -0,0 +1,101 @@ +// ── 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 } diff --git a/server/src/middleware/rateLimit.js b/server/src/middleware/rateLimit.js index b1c71f3..33ab13f 100644 --- a/server/src/middleware/rateLimit.js +++ b/server/src/middleware/rateLimit.js @@ -118,6 +118,18 @@ const passwordResetConfirmLimiter = makeLimiter({ message: 'Too many attempts. Please try again later.', }) +// CSP violation reports. Unauthenticated by necessity (browsers send them with no +// session), and every accepted report writes a log line — so an attacker who can get +// a victim to load a page could otherwise use it as a log-flood amplifier. Generous +// enough for the real case: a genuinely broken directive fires a handful of times per +// page load, and browsers already de-duplicate identical violations per document. +const cspReportLimiter = makeLimiter({ + windowMs: 5 * 60 * 1000, + max: 60, + label: 'csp-report', + message: 'Too many reports.', +}) + module.exports = { loginLimiter, registerLimiter, @@ -129,4 +141,5 @@ module.exports = { mobileSsoExchangeLimiter, passwordResetRequestLimiter, passwordResetConfirmLimiter, + cspReportLimiter, } diff --git a/server/src/router/cspReport.controller.js b/server/src/router/cspReport.controller.js new file mode 100644 index 0000000..ab65a0e --- /dev/null +++ b/server/src/router/cspReport.controller.js @@ -0,0 +1,119 @@ +// ── 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 } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 842e886..896c836 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -125,6 +125,24 @@ } } }, + "/api/csp-report": { + "post": { + "tags": [ + "Health" + ], + "summary": "Content-Security-Policy violation report sink", + "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.", + "responses": { + "204": { + "description": "Report accepted (or ignored). No content." + }, + "429": { + "description": "Too many reports from this address." + } + }, + "security": [] + } + }, "/api/v1/auth/login": { "post": { "tags": [ diff --git a/server/test/csp.test.js b/server/test/csp.test.js new file mode 100644 index 0000000..a320315 --- /dev/null +++ b/server/test/csp.test.js @@ -0,0 +1,171 @@ +// CSP phase 1: the tightened policy rides on Content-Security-Policy-Report-Only for +// one release before it is enforced (docs/website/API_V2_PLAN.md § Phase 1). The whole +// point of that soak is that the live policy is untouched, so the first test here +// pins the enforced header verbatim — if a future edit to config/csp.js changes what +// users are actually protected by, it fails rather than shipping quietly. +// +// Dead-port DB before requiring the app: it pulls in every model, which builds a +// mariadb pool at require time. No route touched here queries anything. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, after } = require('node:test') +const assert = require('node:assert/strict') + +const app = require('../src/app') +const csp = require('../src/config/csp') +const cspReport = require('../src/router/cspReport.controller') +const db = require('../src/utils/db') + +after(() => db.close()) + +// The exact header served before this phase. Not a restatement of config/csp.js — +// captured from the running app on main, so it also catches helmet changing its +// `useDefaults` set underneath us (which is how `form-action` got here in the first +// place: it was already being emitted, and is now pinned explicitly rather than +// inherited from a dependency's defaults). +const ENFORCED_BASELINE = + "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';" + + "form-action 'self';script-src-attr 'none'" + +async function withServer(fn) { + const server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)) + }) + try { + return await fn(`http://127.0.0.1:${server.address().port}`) + } finally { + await new Promise((resolve) => server.close(resolve)) + } +} + +test('the enforced policy is byte-for-byte what it was before the soak', async () => { + const res = await withServer((base) => fetch(`${base}/api/health`)) + assert.equal( + res.headers.get('content-security-policy'), + ENFORCED_BASELINE, + 'Report-only phase must not change what is actually enforced.', + ) +}) + +test('the report-only policy differs from the enforced one only by frame-ancestors', async () => { + const res = await withServer((base) => fetch(`${base}/api/health`)) + const reportOnly = res.headers.get('content-security-policy-report-only') + assert.ok(reportOnly, 'Content-Security-Policy-Report-Only must be served') + + assert.match(reportOnly, /frame-ancestors 'none'/) + assert.doesNotMatch(reportOnly, /frame-ancestors 'self'/) + + // Strip the report plumbing and the one tightened directive; what is left must be + // the live policy. Anything else appearing here would be silently soaking an + // untracked change. + const stripped = reportOnly + .split(';') + .filter((d) => !d.startsWith('report-to') && !d.startsWith('report-uri')) + .map((d) => (d.startsWith('frame-ancestors') ? "frame-ancestors 'self'" : d)) + .join(';') + assert.equal(stripped, ENFORCED_BASELINE) +}) + +test('report-to is backed by a Reporting-Endpoints header, and report-uri by the same path', async () => { + // Without the header the report-to directive is inert and only Firefox/Safari's + // deprecated report-uri would ever fire — i.e. a soak that silently half-works. + const res = await withServer((base) => fetch(`${base}/api/health`)) + assert.equal(res.headers.get('reporting-endpoints'), `${csp.REPORT_GROUP}="${csp.REPORT_PATH}"`) + + const reportOnly = res.headers.get('content-security-policy-report-only') + assert.ok(reportOnly.includes(`report-to ${csp.REPORT_GROUP}`)) + assert.ok(reportOnly.includes(`report-uri ${csp.REPORT_PATH}`)) +}) + +test('the sink is same-origin — reports never leave this host', () => { + assert.ok(csp.REPORT_PATH.startsWith('/'), 'a third-party collector would receive attack data') +}) + +test('the sink accepts both wire formats and always answers 204', async () => { + const statuses = await withServer(async (base) => { + const post = (type, body) => + fetch(`${base}${csp.REPORT_PATH}`, { + method: 'POST', + headers: { 'Content-Type': type }, + body, + }).then((r) => r.status) + + return { + // Firefox / Safari + reportUri: await post( + 'application/csp-report', + JSON.stringify({ 'csp-report': { 'effective-directive': 'frame-ancestors' } }), + ), + // Chrome's Reporting API + reportsJson: await post( + 'application/reports+json', + JSON.stringify([{ type: 'csp-violation', body: { effectiveDirective: 'frame-ancestors' } }]), + ), + // Junk must not produce a 4xx + an ERROR log line, or the open endpoint becomes + // a log-flood primitive. + malformed: await post('application/csp-report', 'not json'), + oversized: await post( + 'application/csp-report', + JSON.stringify({ 'csp-report': { 'script-sample': 'x'.repeat(40000) } }), + ), + empty: await post('application/csp-report', ''), + } + }) + + assert.deepEqual(statuses, { + reportUri: 204, + reportsJson: 204, + malformed: 204, + oversized: 204, + empty: 204, + }) +}) + +test('the sink is POST-only', async () => { + const res = await withServer((base) => fetch(`${base}${csp.REPORT_PATH}`)) + assert.equal(res.status, 404) +}) + +test('normalize() reads the hyphenated report-uri body', () => { + const [v] = cspReport.normalize({ + 'csp-report': { + 'document-uri': 'https://site/page', + 'effective-directive': 'frame-ancestors', + 'blocked-uri': 'https://evil/', + 'script-sample': 'alert(1)', + disposition: 'report', + }, + }) + assert.deepEqual(v, { + documentUrl: 'https://site/page', + directive: 'frame-ancestors', + blockedUrl: 'https://evil/', + disposition: 'report', + sample: 'alert(1)', + }) +}) + +test('normalize() reads the camelCase Reporting-API envelope and drops other report types', () => { + const out = cspReport.normalize([ + { + type: 'csp-violation', + url: 'https://site/page', + body: { effectiveDirective: 'form-action', blockedURL: 'https://evil/' }, + }, + // Browsers deliver deprecation/intervention reports to the same group. + { type: 'deprecation', url: 'https://site/page', body: { id: 'x' } }, + ]) + assert.equal(out.length, 1) + assert.equal(out[0].directive, 'form-action') + assert.equal(out[0].documentUrl, 'https://site/page') +}) + +test('normalize() shrugs at anything else', () => { + assert.deepEqual(cspReport.normalize(undefined), []) + assert.deepEqual(cspReport.normalize({}), []) + assert.deepEqual(cspReport.normalize('nonsense'), []) +})