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
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>
185 lines
7.5 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|