Files
0pt/tests/security/security-headers.test.ts
Simon AxelssonandClaude Opus 5 8ebc90ffa3
masterplan-lock / check (pull_request) Successful in 4s
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / security (push) Successful in 18s
CI (SIAX Cloud) / quality (push) Successful in 39s
CI (SIAX Cloud) / integration (pull_request) Successful in 41s
CI (SIAX Cloud) / integration (push) Successful in 44s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m37s
Security Scan (SIAX Cloud) / security (push) Successful in 1m35s
CI (SIAX Cloud) / quality (pull_request) Successful in 3m4s
fix(security,repo-honesty): real security headers + a doctor that checks truth, not presence
Two defects of the same kind: the repo asserted things about itself that were
not true, and nothing in it could tell.

A. CHANGELOG.md claimed CSP/HSTS/X-Frame-Options since 2026-08. `main`
   contained none of it -- a repo-wide grep for
   `content-security-policy|strict-transport-security|x-frame-options` matched
   exactly one file, CHANGELOG.md itself. The only header on 0pt.app was
   Vercel's own HSTS default, which does not survive the move to
   Coolify/Traefik that siax.config.json and .gitea/workflows/cd.yml are
   already pointed at.

   - next.config.mjs: `headers()` on `/(.*)` with CSP (frame-ancestors 'none',
     base-uri 'self', object-src 'none', form-action 'self'), HSTS,
     X-Frame-Options DENY, X-Content-Type-Options, Referrer-Policy,
     Permissions-Policy; plus poweredByHeader: false. Same idiom as
     sax3l/siax-io's next.config.mjs.
   - Carve-outs re-derived against THIS tree, not inherited: the stale
     `ci/security-headers-and-scanning` branch (8d2ff73, 142 commits behind)
     carried a `form-action https://checkout.stripe.com` allowance justified
     by a redirect that is actually `window.location.href`, which form-action
     does not govern -- dropped rather than kept as decorative permission.
     frame-src is derived from NOCODB_BASE_URL/METABASE_SITE_URL, the same env
     the components/embed.tsx iframe is configured from, so wiring that
     component up cannot be silently blanked by this header (the failure
     sax3l/siax-io recorded as SIO-013). 'unsafe-eval' is dev-only.
   - tests/security/security-headers.test.ts: blocking regression gate inside
     `pnpm run test`, i.e. the required "CI (SIAX Cloud) / quality" context.
     Asserts against expectations written out in the test, not imported from
     the config. Proven to fail: removing X-Frame-Options -> `pnpm run test`
     exits 1; weakening frame-ancestors to 'self' -> exits 1.

B. scripts/siax-doctor.mjs validated that siax.repo.json fields were PRESENT,
   never what they said. Measured on this tree: operationalState "running"
   (false) and "down" (true) both gave 40/40, exit 0, byte-identical output.

   - Value checks (enums, git.siax.io const, fleet node pattern, bare-hostname
     form), cross-file consistency (declared prod hostnames must appear in
     this repo's own siax.config.json / contracts/openapi.yaml, and vice
     versa), and GOV-06 substantiation: operationalState must be backed by a
     dated probe record in evidence/operational-state.json that agrees with
     it. Liveness is not knowable offline; whether a liveness CLAIM is backed
     is. Offline, deterministic, no network -- CI can never go red for want of
     an internet connection.
   - `--live` (opt-in) probes the declared hosts for real and checks the
     headers on the wire; `--write-evidence` refreshes the record. Not wired
     into CI on purpose.
   - tests/repo/siax-doctor.test.ts runs the doctor inside `pnpm run test` and
     asserts the negative cases against fixture trees (SIAX_DOCTOR_ROOT), so
     the "cannot fail" defect cannot come back.

C. CHANGELOG.md is now true. The header claim stands; the four claims it was
   bundled with (Trivy --exit-code 1, gitleaks, SHA-pinned actions, SBOM +
   license artifacts) are still false on main and are listed as not
   implemented instead of left asserted. All four live in .gitea/workflows/**,
   a protected path on this repo, so they need an authorised workflow change.

Nothing under .gitea/workflows/** or .gitignore is touched.

Verified: pnpm run typecheck exit 0; pnpm run test exit 0 (74 files / 965
tests passed, up from 72/945); pnpm run build exit 0 and the built
.next/routes-manifest.json carries all six headers; node scripts/siax-doctor.mjs
55/55 exit 0; --live 57/57 exit 0. eslint on the new files exit 0 (repo lint is
advisory and its 17 pre-existing problems are unchanged).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 02:43:32 +02:00

185 lines
7.5 KiB
TypeScript

/**
* 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<HeaderRule[]> }> {
// 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<ReturnType<typeof loadNextConfig>> };
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);
}
});
});