Files
0pt/next.config.mjs
T
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

180 lines
9.0 KiB
JavaScript

// =============================================================================
// 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 <form action> exists (the only
// <form> 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 <iframe> from a
* server-generated signed URL. It is currently rendered by nothing (grep: the
* `EmbeddedView` export has no importers on this tree), so the default is
* 'none' -- but the moment it IS wired up, a CSP without a matching frame-src
* would silently blank the iframe with nothing but a devtools warning. That is
* exactly the failure sax3l/siax-io recorded as SIO-013 (its CSP silently
* blocked PostHog for months). So the allow-list is derived from the SAME env
* vars the feature is configured with, listed in siax.config.json `env`.
*
* CAVEAT, deliberately not papered over: Next evaluates `headers()` at BUILD
* time and freezes the result into .next/routes-manifest.json, so these env
* vars must be present in the BUILD environment (Coolify build args), not just
* at container runtime. Changing them on a running container has no effect
* until the next build.
*/
function frameSrc() {
const origins = []
for (const raw of [process.env.NOCODB_BASE_URL, process.env.METABASE_SITE_URL]) {
if (!raw) continue
try {
origins.push(new URL(raw).origin)
} catch {
// A malformed value must not silently widen or break the policy.
}
}
return origins.length > 0 ? `frame-src ${[...new Set(origins)].join(" ")}` : "frame-src 'none'"
}
const csp = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${IS_DEV ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self'",
frameSrc(),
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'",
"object-src 'none'",
].join("; ")
const securityHeaders = [
// `preload` is inert until 0pt.app is actually submitted at hstspreload.org.
// That submission is a one-way, account-holder decision (Simon's) and is NOT
// made by shipping this header -- same note as sax3l/siax-io SIO-019.
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
// DENY, not SAMEORIGIN: nothing in this app frames itself.
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=(), usb=()" },
{ key: "Content-Security-Policy", value: csp },
]
/** @type {import('next').NextConfig} */
const nextConfig = {
// Självhostad drift i SIAX Cloud (Coolify + Traefik) — ersätter Vercel.
//
// "standalone" får `next build` att packa en fristående Node-server i
// .next/standalone (server.js + endast de spårade modulerna). Runtime-imagen
// behöver då varken pnpm, corepack eller ett fullt node_modules-träd: den kör
// `node server.js`. Det är det som gör att Dockerfilens runtime-steg kan
// sluta släpa med npm/npx och hela dev-beroendeträdet.
//
// Inställningen är ofarlig på Vercel — plattformen ignorerar den — så den kan
// landa före DNS-cutovern utan att röra den nuvarande deployen. Samma mönster
// som sax3l/siax-io, som redan körs självhostat på flottan.
output: "standalone",
// Skicka inte `X-Powered-By: Next.js`. Ingen funktionell skillnad, tar bara
// bort en gratis versionsupplysning.
poweredByHeader: false,
// OFÖRÄNDRAD. Pre-existerande skuld i den v0-genererade app/ + components/-
// koden (se package.json's "//scripts"-not och `typecheck:app`). Den
// BLOCKERANDE grinden är `pnpm run typecheck` över src/ + tests/, som inte
// rörs av den här ändringen. Att slå på flaggan är sitt eget arbete.
typescript: {
ignoreBuildErrors: true,
},
images: {
// `unoptimized: true` togs bort. Det var v0-generatorns standardvärde —
// next.config.mjs hade inte ändrats sedan "Initial commit from v0"
// (`git log -- next.config.mjs`) — alltså varken ett medvetet val eller en
// workaround för Vercels bildloader. Självhostat är rätt lösning att låta
// Next optimera själv.
//
// FÖRUTSÄTTNING, och den är inte gratis: Nexts bildoptimerare laddar
// `sharp` med en runtime-`require`, inte ett statiskt import. `next build`
// spårar därför INTE sharp in i .next/standalone — VERIFIERAT: ett bygge
// med enbart `output: "standalone"` gav ett .next/standalone/node_modules
// som bara innehöll next/, react/ och react-dom/. En container byggd så
// svarar fel på varje /_next/image-begäran trots att sharp finns i
// pnpm-lock.yaml (som optionalDependency till next@16.3.3).
//
// `outputFileTracingIncludes` är INTE lösningen här: globben
// "./node_modules/@img/**/*" får Turbopack att panika
// ("reading file ... @img/sharp-libvips-linux-x64 — Is a directory") när den
// träffar pnpms katalogsymlänkar inne i node_modules/.pnpm. Reproducerat i
// det här arbetet; bygget avslutades med exit 1.
//
// Sharp levereras i stället som ett eget, symlänkfritt npm-steg i
// Dockerfile (`FROM node:22-alpine AS sharp`), pinnat till exakt den version
// pnpm-lock.yaml redan löst ut. Se Dockerfilens kommentar.
//
// Ingen komponent renderar en fjärrbild i dag (`next/image` importeras på
// exakt ett ställe, components/fade-image.tsx; alla bilder ligger i
// public/), så ingen `remotePatterns` behövs ännu. Lägg till en sådan —
// aldrig den föråldrade `domains` — den dagen en extern bildvärd tillkommer.
formats: ["image/avif", "image/webp"],
},
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
]
},
}
export default nextConfig