// 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'), []) })