/** * BACKLOG P0-13 / SEC-06 -- security-header REGRESSION GATE. * * Why this file exists: CHANGELOG.md asserted "security headers * (CSP/HSTS/X-Frame-Options/etc.)" while `main` contained zero such code for * weeks, and nothing in the repo could tell the difference. Adding the headers * without adding a gate would just reset that clock. * * WHAT THIS PROVES: that `next.config.mjs` -- the object Next.js actually * consumes -- still declares the required headers, with values that are still * strict. It reads the real config module; it does not re-import the header * list and compare it to itself. Every expectation below is written out here, * independently, so deleting or weakening a header in next.config.mjs fails * this test rather than silently changing both sides. * * WHAT IT DOES NOT PROVE: that a running instance serves them. Next freezes * `headers()` into .next/routes-manifest.json at build time, so the manifest * assertion at the bottom runs opportunistically when a build exists (locally, * or in any job that builds before testing). End-to-end, against a live * origin, is `node scripts/siax-doctor.mjs --live`. * * Runs inside `pnpm run test` -> the BLOCKING "CI (SIAX Cloud) / quality" job. */ import { describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); type HeaderEntry = { key: string; value: string }; type HeaderRule = { source: string; headers: HeaderEntry[] }; /** * Required headers and how strict each must stay. Hardcoded on purpose -- * this is the independent side of the comparison. */ const REQUIRED: { key: string; assert: (value: string) => void }[] = [ { key: "Strict-Transport-Security", assert: (v) => { const maxAge = /max-age=(\d+)/.exec(v); expect(maxAge, `HSTS must carry a max-age, got: ${v}`).not.toBeNull(); // >= 1 year: below that the header is decorative and ineligible for preload. expect(Number(maxAge![1])).toBeGreaterThanOrEqual(31536000); expect(v).toMatch(/includeSubDomains/i); }, }, { key: "X-Frame-Options", // SAMEORIGIN would be a weakening for this app: nothing here frames itself. assert: (v) => expect(v.trim().toUpperCase()).toBe("DENY"), }, { key: "X-Content-Type-Options", assert: (v) => expect(v.trim().toLowerCase()).toBe("nosniff"), }, { key: "Referrer-Policy", assert: (v) => expect(["no-referrer", "same-origin", "strict-origin", "strict-origin-when-cross-origin"]).toContain( v.trim().toLowerCase(), ), }, { key: "Permissions-Policy", assert: (v) => { for (const feature of ["camera", "microphone", "geolocation"]) { expect(v, `Permissions-Policy must disable ${feature}`).toMatch( new RegExp(`${feature}\\s*=\\s*\\(\\s*\\)`), ); } }, }, { key: "Content-Security-Policy", assert: (v) => { const directives = new Map( v .split(";") .map((d) => d.trim()) .filter(Boolean) .map((d) => { const [name, ...rest] = d.split(/\s+/); return [name.toLowerCase(), rest.join(" ")] as const; }), ); expect(directives.get("default-src")).toBe("'self'"); // Clickjacking defence that actually applies to modern browsers. expect(directives.get("frame-ancestors")).toBe("'none'"); expect(directives.get("base-uri")).toBe("'self'"); expect(directives.get("object-src")).toBe("'none'"); expect(directives.get("form-action")).toBe("'self'"); expect(directives.has("script-src")).toBe(true); expect(directives.has("connect-src")).toBe(true); expect(directives.has("frame-src")).toBe(true); // No wildcard sources anywhere: a bare `*` re-opens what this header exists to close. for (const [name, value] of directives) { expect(value.split(/\s+/), `CSP directive ${name} must not allow *`).not.toContain("*"); } // 'unsafe-eval' is a dev-only relaxation (see next.config.mjs). Vitest runs // with NODE_ENV=test, so it must be absent here; if it ever appears outside // `next dev`, this fails. expect(process.env.NODE_ENV, "vitest is expected to run outside NODE_ENV=development").not.toBe( "development", ); expect(v, "'unsafe-eval' must never ship outside next dev").not.toContain("unsafe-eval"); }, }, ]; /** Rule sources that cover every path Next can serve. */ const CATCH_ALL_SOURCES = new Set(["/(.*)", "/:path*", "/(.*)?"]); async function loadNextConfig(): Promise<{ poweredByHeader?: boolean; headers?: () => Promise }> { // Computed specifier on purpose: keeps `next.config.mjs` (a JS file outside // tsconfig.check.json's include) out of the blocking typecheck's graph. const url = new URL("../../next.config.mjs", import.meta.url).href; const mod = (await import(/* @vite-ignore */ url)) as { default: Awaited> }; return mod.default; } describe("SEC-06: 0pt serves its own security headers (P0-13)", () => { it("next.config.mjs declares a headers() rule covering every path", async () => { const config = await loadNextConfig(); expect(typeof config.headers, "next.config.mjs must export an async headers()").toBe("function"); const rules = await config.headers!(); expect(Array.isArray(rules)).toBe(true); const catchAll = rules.filter((r) => CATCH_ALL_SOURCES.has(r.source)); expect( catchAll.length, `no catch-all header rule; sources were: ${rules.map((r) => r.source).join(", ")}`, ).toBeGreaterThan(0); }); it("does not advertise the framework via X-Powered-By", async () => { const config = await loadNextConfig(); expect(config.poweredByHeader).toBe(false); }); for (const { key, assert } of REQUIRED) { it(`sets ${key} on every path, and keeps it strict`, async () => { const config = await loadNextConfig(); const rules = await config.headers!(); const applied = rules .filter((r) => CATCH_ALL_SOURCES.has(r.source)) .flatMap((r) => r.headers) .filter((h) => h.key.toLowerCase() === key.toLowerCase()); expect( applied.length, `${key} is not set for all paths -- CHANGELOG.md claims 0pt ships it. ` + `Either restore it in next.config.mjs or stop claiming it.`, ).toBe(1); assert(applied[0].value); }); } it("the built routes-manifest carries the same headers (skipped when no build exists)", async () => { const manifestPath = path.join(REPO_ROOT, ".next", "routes-manifest.json"); if (!existsSync(manifestPath)) { // `pnpm run test` runs before `pnpm run build` in .gitea/workflows/ci.yml, // so on a clean CI checkout there is nothing to read. Not a silent pass: // the config-level assertions above already ran and are blocking. return; } const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { headers?: { source?: string; regex?: string; headers?: HeaderEntry[] }[]; }; const built = (manifest.headers ?? []).flatMap((r) => r.headers ?? []); expect(built.length, "a build exists but its routes-manifest has no headers").toBeGreaterThan(0); for (const { key } of REQUIRED) { expect( built.some((h) => h.key.toLowerCase() === key.toLowerCase()), `${key} missing from .next/routes-manifest.json -- it would not be served`, ).toBe(true); } }); });