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 `