1/** 2 * Security-header policy — the SINGLE SOURCE OF TRUTH for the web app's 3 * security headers (TASK-0134, OWASP ASVS L2 §7 / §9). 4 * 5 * Why here: the policy must be applied by `apps/web/next.config.mjs` (the 6 * `headers()` API — a Node script that bundles Next's webpack toolchain 7 * cannot import a bare `.ts` module through the bundler) AND asserted by 8 * the security suite (`tests/security/headers.test.ts`). Both consumers 9 * import this exact module, so the web headers and the test expectations 10 * cannot drift. The API (`apps/api/src/main.ts`) serves JSON (CSP 11 * `default-src 'none'`) and keeps its own small header block; the web is 12 * the only surface that renders HTML to end users. 13 * 14 * The CSP is deliberately app-specific (documented per directive): 15 * 16 * - `default-src 'self'` — nothing from any other origin by default. 17 * - `script-src 'self' 'unsafe-inline'` — `'self'` for the chunked static 18 * JS; `'unsafe-inline'` is REQUIRED by Next.js 14 App Router: the SSR 19 * flight payload is delivered as inline `<script>self.__next_f.push(...)` 20 * scripts (no external flight file). No third-party script origin. 21 * Dev adds `'unsafe-eval'` (Next 14 dev chunks embed inline source maps 22 * via eval(); without it client hydration is blocked) — production never 23 * carries 'unsafe-eval'. 24 * - `style-src 'self' 'unsafe-inline'` — Tailwind + next/font + inline 25 * style attributes used by the editor (block decorations). No external 26 * stylesheet origin. 27 * - `img-src 'self' data: blob:` — same-origin static media + the file 28 * pipeline serves user images through the API (same-origin `/files/*`) 29 * or as data:/blob: URLs in the editor canvas. 30 * - `font-src 'self'` — next/font self-hosts woff2 under /_next/static/media. 31 * - `connect-src 'self'` + (env) API + WS origins — fetch() to the same 32 * origin plus the configured API/WS endpoints (dev: localhost:4000/4001). 33 * - `frame-src 'self' http: https:` — the editor's EmbedBlock and the 34 * PDF/file preview iframes load user-supplied URLs (sandboxed with the 35 * `sandbox` attribute; see block-renderer.tsx). `default-src 'self'` 36 * would break this feature, so frame-src is explicit. 37 * - `object-src 'none'` — no plugins (Flash/etc.) anywhere. 38 * - `base-uri 'self'` — blocks `<base href>` injection (DOM hijack). 39 * - `form-action 'self'` — forms (public-form embeds POST via fetch, not 40 * navigation) may only submit to this origin. 41 * - `frame-ancestors 'self'` — clickjacking protection in the CSP form; 42 * `X-Frame-Options: DENY` is sent as well (belt + suspenders, and for 43 * browsers that predate frame-ancestors support). 44 * - `upgrade-insecure-requests` — NOT set: dev is plain HTTP; production 45 * deployments terminate TLS at the edge and the HSTS there handles 46 * upgrades. (Documented deviation from the "strictest default".) 47 * 48 * Non-CSP headers (ASVS §7.1–§7.4): 49 * - X-Content-Type-Options: nosniff 50 * - X-Frame-Options: DENY 51 * - Referrer-Policy: no-referrer (the strictest policy that still works; 52 * the app never needs referrer data) 53 * - Permissions-Policy: geolocation=(), microphone=(), camera=() — the 54 * app uses none of these; deny-by-default at the browser level. 55 * - Strict-Transport-Security — production ONLY (plain-HTTP dev would be 56 * broken by HSTS; the edge adds it in prod). 57 */ 58 59export interface SecurityHeadersOptions { 60 /** The API origin (no trailing slash, e.g. `http://localhost:4000`). */ 61 apiOrigin?: string; 62 /** The realtime WebSocket origin (e.g. `ws://localhost:4001`). */ 63 wsOrigin?: string; 64 /** Production flag (controls HSTS). */ 65 production?: boolean; 66} 67 68export type SecurityHeaderMap = Record<string, string>; 69 70/** The web app's Content-Security-Policy (see the directive-by-directive notes above). */ 71export function webContentSecurityPolicy(opts: SecurityHeadersOptions = {}): string { 72 const connect = ["'self'"]; 73 if (opts.apiOrigin) connect.push(opts.apiOrigin.replace(/\/$/, "")); 74 if (opts.wsOrigin) { 75 connect.push(opts.wsOrigin.replace(/\/$/, "")); 76 // fetch()-based upgrades are not used (the realtime client uses 77 // `new WebSocket`), which `connect-src` covers; be explicit regardless. 78 const httpWs = opts.wsOrigin 79 .replace(/\/$/, "") 80 .replace(/^ws:/, "http:") 81 .replace(/^wss:/, "https:"); 82 connect.push(httpWs); 83 } 84 const scriptSrc = ["'self'", "'unsafe-inline'"]; 85 // DEV-ONLY 'unsafe-eval': the Next.js 14 dev server emits webpack chunks that 86 // embed inline source maps via eval() (180+ eval() calls in main-app.js), so 87 // a CSP without 'unsafe-eval' blocks client hydration and the whole app 88 // freezes at the SSR "Loading workspace…" spinner (TASK-0135 e2e regression — 89 // the TASK-0134 security-audit header change broke every web e2e spec on the 90 // dev box). Production builds contain no eval() chunks, so the prod CSP 91 // (opts.production) must NOT carry 'unsafe-eval'. 92 if (!opts.production) scriptSrc.push("'unsafe-eval'"); 93 const parts = [ 94 "default-src 'self'", 95 `script-src ${scriptSrc.join(" ")}`, 96 `style-src 'self' 'unsafe-inline'`, 97 `img-src 'self' data: blob:`, 98 `font-src 'self'`, 99 `connect-src ${connect.join(" ")}`, 100 `frame-src 'self' http: https:`, 101 `object-src 'none'`, 102 `base-uri 'self'`, 103 `form-action 'self'`, 104 `frame-ancestors 'self'` 105 ]; 106 return parts.join("; "); 107} 108 109/** 110 * The full web-app security header map. `next.config.mjs` calls this once 111 * (dev values); the security suite calls it with the SAME values and 112 * compares the live response, so a drift in either side fails the build. 113 */ 114export function webSecurityHeaders(opts: SecurityHeadersOptions = {}): SecurityHeaderMap { 115 const headers: SecurityHeaderMap = { 116 "content-security-policy": webContentSecurityPolicy(opts), 117 "x-content-type-options": "nosniff", 118 "x-frame-options": "DENY", 119 "referrer-policy": "no-referrer", 120 "permissions-policy": "geolocation=(), microphone=(), camera=()" 121 }; 122 if (opts.production) { 123 headers["strict-transport-security"] = "max-age=31536000; includeSubDomains"; 124 } 125 return headers; 126} 127