From 89a435f83720fcb5676bd50b1bbefc451ab9c0be Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Mon, 7 Sep 2026 20:45:58 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 53 ++- CODEOWNERS | 8 + evidence/operational-state.json | 49 +++ next.config.mjs | 120 ++++++ scripts/siax-doctor.mjs | 524 ++++++++++++++++++++++-- tests/repo/siax-doctor.test.ts | 209 ++++++++++ tests/security/security-headers.test.ts | 184 +++++++++ 7 files changed, 1108 insertions(+), 39 deletions(-) create mode 100644 CODEOWNERS create mode 100644 evidence/operational-state.json create mode 100644 tests/repo/siax-doctor.test.ts create mode 100644 tests/security/security-headers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ae77a..43b3c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,38 @@ ## [Unreleased] -- fix(security): security headers (CSP/HSTS/X-Frame-Options/etc.) + CI - security gates actually enforce (Trivy `--exit-code 1`, gitleaks) instead - of advisory-only. Actions pinned to commit SHA. SBOM + license inventory - as CI artifacts. (BACKLOG P0-13, P0-16, P0-19, P1-14, P1-15) +- fix(security): response security headers are set by the app itself, in + `next.config.mjs` `headers()` — CSP (incl. `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`. Regression-gated by + `tests/security/security-headers.test.ts` (blocking, runs in + `pnpm run test`) and re-checkable against a live origin with + `node scripts/siax-doctor.mjs --live`. (BACKLOG P0-13, P1-15) + + Correcting this file: from 2026-08 until 2026-09-07 this bullet also + claimed the headers themselves, while `main` contained none — a repo-wide + grep for `content-security-policy|strict-transport-security| + x-frame-options` matched only CHANGELOG.md. The only thing setting HSTS on + 0pt.app was Vercel's platform default (still observable on the 402 + responses recorded in `evidence/operational-state.json`), which is not this + repo's and does not survive the move to Coolify/Traefik. The claim is true + as of this entry; the four claims it was bundled with are not, and are + moved below rather than left standing. + +- fix(repo-honesty): `scripts/siax-doctor.mjs` validated that + `siax.repo.json` fields were PRESENT, never what they said — measured: + `operationalState: "running"` (false at the time; every declared host + answered 402/404) and `"down"` (true) both produced 40/40 and + byte-identical output. It now validates values (enums, hostname form), + cross-file consistency (declared prod hostnames must appear in this repo's + own deploy/contract config, and vice versa), and requires + `operationalState` to be backed by a dated probe record in + `evidence/operational-state.json` that agrees with it. Offline and + deterministic by default; `--live` (opt-in) probes the real hosts. + Gated by `tests/repo/siax-doctor.test.ts`, which also asserts the negative + cases against fixture trees. (BACKLOG P1-19, control AGENT-04, GOV-06) + - fix(health): `/api/health` reports real per-dependency status instead of a hardcoded `ok: true`; new `/api/ready` readiness endpoint. (BACKLOG P0-3) - docs/chore: repo-hygiene pass — schema-valid `siax.repo.json`/`siax.config.json` @@ -19,6 +47,23 @@ `docs/architecture/ARCHITECTURE.md` §5 "Ytor och paritet", CODEOWNERS, stale-link cleanup. (BACKLOG P1-1, P1-2, P1-3, P1-19, P1-20, P1-21, P2-5, P3-5) +### Claimed here before 2026-09-07 but NOT implemented (kept visible, not deleted) + +These were asserted by the `fix(security)` bullet above and were never true +on `main`. They are listed so the gap is legible instead of silently dropped; +all four live in `.gitea/workflows/**`, which is a PROTECTED path on this +repo (`protected_file_patterns: .gitea/workflows/**;.gitignore`), so they +need an authorised workflow change rather than an ordinary PR: + +- CI security gates are still ADVISORY, not enforcing: both + `.gitea/workflows/security.yml` and `nightly.yml` run + `trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --exit-code 0`. + `--exit-code 1` was claimed. (BACKLOG P0-16) +- gitleaks is not run anywhere in this repo. (BACKLOG P0-19) +- Actions are NOT pinned to commit SHAs — every workflow uses + `actions/checkout@v4` / `actions/setup-node@v4` tags. (BACKLOG P1-14) +- No SBOM and no license inventory are produced or uploaded as CI artifacts. + ## 2026-08-11 — Evasion-stack decision provenance - Attributed and dated the account-owner's keep-decision on `services/scrapers/*` diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..0134e0f --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,8 @@ +# BACKLOG P1-15 (CI-14): required reviewers for Gitea's branch-protection +# "required approvals" setting to have anyone to assign. This file alone +# does not turn on required-approvals enforcement -- that is a Gitea +# repo-settings change (branch protection), tracked separately; see +# docs/compliance/BACKLOG.md P1-15 ("required_approvals: 0"). +# +# Gitea CODEOWNERS syntax matches GitHub's: path pattern, then owner(s). +* @sax3l diff --git a/evidence/operational-state.json b/evidence/operational-state.json new file mode 100644 index 0000000..5d4574f --- /dev/null +++ b/evidence/operational-state.json @@ -0,0 +1,49 @@ +{ + "$comment": [ + "GOV-06 substantiation record for siax.repo.json's `operationalState`.", + "", + "WHY THIS FILE EXISTS: `operationalState` is a claim about the world, and", + "scripts/siax-doctor.mjs cannot check the world from CI (no network, and a", + "check that needs the internet is a check that goes red for the wrong", + "reason). What the doctor CAN do offline is refuse to accept the claim", + "unless it is backed by a dated, machine-readable probe result that agrees", + "with it. That is this file. Setting operationalState to \"running\" while", + "the probes below say 402/404 now fails `pnpm siax:doctor`.", + "", + "HOW TO REFRESH: `node scripts/siax-doctor.mjs --live --write-evidence`", + "(opt-in, needs network). Or edit by hand from a real probe -- but the", + "doctor cross-checks `ok` against `httpStatus`, so a hand-written", + "`ok: true` next to a 402 is itself a failure." + ], + "control": "GOV-06", + "operationalState": "down", + "checkedAt": "2026-09-07T18:24:24Z", + "method": "curl -sS -o /dev/null -D - --max-time 20 , from the public internet (not the tailnet)", + "probes": [ + { + "fqdn": "0pt.app", + "env": "prod", + "url": "https://0pt.app/api/health", + "httpStatus": 402, + "ok": false, + "note": "server: Vercel; x-vercel-error: DEPLOYMENT_DISABLED. The Vercel deployment is disabled and no Coolify app is serving this hostname yet." + }, + { + "fqdn": "api.0pt.app", + "env": "prod", + "url": "https://api.0pt.app/api/health", + "httpStatus": 404, + "ok": false, + "note": "server: Vercel; x-vercel-error: DEPLOYMENT_NOT_FOUND. Contract-first target host (contracts/openapi.yaml servers), never deployed. Both / and /api/health return the same 404." + } + ], + "securityHeadersObserved": { + "$comment": "Observed on the 402/404 responses above, i.e. served by Vercel's edge, NOT by this app. This is the evidence for BACKLOG P0-13: everything except Vercel's own HSTS default is absent, and that default does not survive a move to Coolify/Traefik.", + "strict-transport-security": "max-age=63072000", + "content-security-policy": null, + "x-frame-options": null, + "x-content-type-options": null, + "referrer-policy": null, + "permissions-policy": null + } +} diff --git a/next.config.mjs b/next.config.mjs index 4cd9948..88128f9 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,11 +1,131 @@ +// ============================================================================= +// 0pt -- Next.js config +// ============================================================================= +// SECURITY HEADERS (BACKLOG P0-13 / SEC-06). +// +// CHANGELOG.md has claimed since 2026-08 that this repo ships +// CSP/HSTS/X-Frame-Options. Until this commit that claim was FALSE on `main`: +// a repo-wide grep for `content-security-policy|strict-transport-security| +// x-frame-options` matched exactly one file -- CHANGELOG.md itself. There was +// no `headers()`, no middleware header injection, no reverse-proxy config in +// this repo. The only thing that ever set HSTS on 0pt.app was Vercel's own +// platform default, which is not this repo's and disappears the moment the app +// moves to Coolify/Traefik (siax.config.json `deploy.target` is already +// "coolify"; .gitea/workflows/cd.yml already points at a real Coolify app id). +// Setting them here, at the framework level, means they travel with the app +// regardless of host -- same idiom as sax3l/siax-io's next.config.mjs. +// +// CARVE-OUTS, each grep-verified against THIS tree (2026-09-07), not inherited +// from an older branch: +// +// * script-src/style-src 'unsafe-inline' -- Next's App Router injects inline +// hydration/RSC-payload scripts and next-themes injects an inline +// no-flash script; Tailwind + recharts emit inline style attributes. A +// per-request nonce threaded through middleware + layout is the real fix +// and is deliberately NOT attempted here: this commit closes "zero +// headers", not "zero inline tolerance". Tracked in BACKLOG. +// * 'unsafe-eval' is added ONLY in `next dev` (webpack/Turbopack eval source +// maps + HMR). Production never gets it. Guarded on NODE_ENV so a +// dev-only relaxation can never leak into a built image, whose runtime +// stage sets NODE_ENV=production (see Dockerfile). +// * form-action 'self' -- no cross-origin
exists (the only +// in the tree, app/portal/integrationer/page.tsx, is onSubmit-only). +// The Stripe hop in app/bestall/forskott/page.tsx is +// `window.location.href = body.url`, i.e. a top-level navigation, which no +// shipped CSP directive gates -- `form-action` would not have applied to +// it. An earlier draft of this change carried a +// `form-action https://checkout.stripe.com` carve-out on that mistaken +// premise; it is dropped here rather than kept as decorative permission. +// * connect-src 'self' -- every external host in the tree +// (api.stripe.com, graph.microsoft.com, login.microsoftonline.com, +// id-customers.siax.io, api.opensanctions.org, opendata.rdw.nl) is reached +// from server-side route handlers / adapters, never from the browser. +// * frame-src -- see FRAME_SRC below. This is the one directive with a live +// trap in it, so it is derived from env rather than hardcoded to 'self'. +// +// WHAT THIS FILE CANNOT DO: it sets response headers. It does not prove they +// are served. `tests/security/security-headers.test.ts` asserts this config +// keeps them (blocking, runs in "CI (SIAX Cloud) / quality"), and +// `node scripts/siax-doctor.mjs --live` re-checks them against a running +// instance. Neither replaces the other. +// ============================================================================= + +const IS_DEV = process.env.NODE_ENV === "development" + +/** + * `components/embed.tsx` renders a NocoDB / Metabase view in an