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>
180 lines
9.0 KiB
JavaScript
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
|