/** * TASK-0134 — security headers (ASVS L2 §7/§9; OWASP "Security Misconfiguration" * + "Insecure CSP"): every web response carries the policy, the policy is the * shared single source (`@factory/shared/security-headers.ts`), and the API's * JSON responses keep their own (stricter, `default-src 'none'`) policy. * * FINDING FIXED BY THIS SUITE: the web app (`apps/web`) previously sent NO * security headers at all (no CSP, no X-Frame-Options, no nosniff, no * Referrer-Policy) — `next.config.mjs` had no `headers()` entry. The fix adds * the policy (dev-verified: login page + root redirect + 404 now carry all * headers); the API (`apps/api/src/main.ts`) already sent its own and is * asserted here as well. * * The web CSP is app-specific (documented per-directive in the policy * module): Next.js 14 App Router flight data is delivered as INLINE scripts * (`script-src 'unsafe-inline'` is required, no alternative in 14.x), the * editor's EmbedBlock + PDF previews load user-supplied iframe URLs * (`frame-src 'self' http: https:`, sandboxed), and the API/WS dev origins * are allowed for `connect-src`. No third-party script/style/img/font * origin exists in the app (verified by the static walk below). * * Live tests skip (not fail) when the stack is down; SEC_REQUIRE_LIVE=1 (CI) * makes a down stack fail the suite. */ import { describe, expect, it } from "vitest"; import { readFileSync, readdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { webSecurityHeaders, webContentSecurityPolicy } from "@factory/shared/security-headers"; import { checkLiveApp, apiFetch, webFetch } from "./helpers/app.js"; const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); const WEB = fileURLToPath(new URL("../../apps/web", import.meta.url)); const SHARED_POLICY = fileURLToPath(new URL("../../packages/shared/src/security-headers.ts", import.meta.url)); // The dev origins MUST match apps/web/src/lib/constants.ts defaults (the // suite asserts the config file uses the same NEXT_PUBLIC_* vars). const DEV = { apiOrigin: "http://localhost:4000", wsOrigin: "ws://localhost:4001", production: false }; const EXPECTED = webSecurityHeaders(DEV); const EXPECTED_CSP = EXPECTED["content-security-policy"]!; // Live web responses are asserted against the CSP the RUNNING server was // BUILT with — next.config.mjs derives the connect-src origins from the same // NEXT_PUBLIC_API_URL / NEXT_PUBLIC_WS_URL the client uses (the static test // above asserts the config reads those vars). The web is a compiled server, so // its CSP is frozen at build time; the live assertion must use the deployed // origins, not the dev ones, or it fails whenever the app is served from a // non-localhost origin (e.g. the deployed https origin). Derive them the exact // way next.config.mjs does, with the same dev defaults, so a dev-stack run // (no NEXT_PUBLIC_* set) behaves identically to before. const LIVE_API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000/api/v1"; const LIVE_WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:4001"; const LIVE = { apiOrigin: LIVE_API_URL.replace(/\/api\/v1\/?$/, ""), wsOrigin: LIVE_WS_URL, production: false, }; const EXPECTED_LIVE = webSecurityHeaders(LIVE); const live = await checkLiveApp(); const itLive = it.skipIf(!live); // --------------------------------------------------------------------------- // 1. The policy itself (static — runs without the stack) // --------------------------------------------------------------------------- describe("headers / policy shape (static)", () => { function directives(csp: string): Record { const out: Record = {}; for (const part of csp.split(";").map((s) => s.trim()).filter(Boolean)) { const [name, ...rest] = part.split(/\s+/); out[name!] = rest; } return out; } it("the CSP has a strict default and no third-party script origin; 'unsafe-eval' is dev-only", () => { const d = directives(EXPECTED_CSP); expect(d["default-src"]).toEqual(["'self'"]); const script = d["script-src"] ?? []; expect(script).toContain("'self'"); // 'unsafe-eval' is NOT allowed in the PRODUCTION policy — prod webpack // chunks carry no eval() (only Next 14 DEV chunks do, via inline source // maps; blocking it there froze client hydration — TASK-0135 e2e // regression, fixed by making 'unsafe-eval' dev-only). const prodCsp = webSecurityHeaders({ ...DEV, production: true })["content-security-policy"]!; expect(directives(prodCsp)["script-src"], "no eval in production") .not.toContain("'unsafe-eval'"); // ...but the DEV policy carries it so the dev server's eval()-based // source-map chunks can hydrate the app (asserted explicitly, so the // relaxation can't be silently removed and break dev again). expect(script, "dev hydration requires 'unsafe-eval'").toContain("'unsafe-eval'"); // 'unsafe-inline' is REQUIRED by Next.js 14 flight scripts (documented in // the policy module) — but NO http(s) script origin may appear. expect(script.filter((s) => /^https?:\/\//.test(s))).toHaveLength(0); for (const k of ["object-src", "base-uri", "form-action", "frame-ancestors"]) { expect(d[k], `directive ${k} present`).toBeDefined(); } expect(d["object-src"]).toEqual(["'none'"]); expect(d["frame-ancestors"]).toEqual(["'self'"]); }); it("connect-src allows only same-origin + the configured API/WS origins", () => { const d = directives(webContentSecurityPolicy(DEV)); const connect = d["connect-src"]!; expect(connect).toContain("'self'"); for (const c of connect) { if (c === "'self'") continue; expect(c, `connect-src entry "${c}" is a configured origin`).toMatch( /^(https?|wss?):\/\/(localhost|127\.0\.0\.1)(:\d+)?$/ ); } }); it("HSTS is production-only (absent in dev)", () => { expect(EXPECTED["strict-transport-security"]).toBeUndefined(); const prod = webSecurityHeaders({ ...DEV, production: true }); expect(prod["strict-transport-security"]).toBe("max-age=31536000; includeSubDomains"); }); it("the non-CSP headers are present with the canonical values", () => { expect(EXPECTED["x-content-type-options"]).toBe("nosniff"); expect(EXPECTED["x-frame-options"]).toBe("DENY"); expect(EXPECTED["referrer-policy"]).toBe("no-referrer"); expect(EXPECTED["permissions-policy"]).toBe("geolocation=(), microphone=(), camera=()"); }); it("next.config.mjs wires the policy through headers() (the fix is in place)", () => { const cfg = readFileSync(WEB + "/next.config.mjs", "utf8"); expect(cfg).toContain("@factory/shared/security-headers"); expect(cfg).toContain("webSecurityHeaders("); expect(cfg).toContain("async headers()"); // The catch-all source pattern (quote-free assertion — the config carries // the pattern in string form): expect(cfg).toContain("source: " + JSON.stringify("/(.*)")); // The connect-src origins must come from the SAME env vars the runtime // client uses (constants.ts) — a drift would break the app under CSP. expect(cfg).toContain("NEXT_PUBLIC_API_URL"); expect(cfg).toContain("NEXT_PUBLIC_WS_URL"); }); it("the app has no third-party script/style/font/img origin (CSP completeness)", () => { // The CSP allows only 'self' (+ inline) for scripts and fonts — so any // external