DO NOT MERGE (deploys) — fix(security,repo-honesty): real security headers + a doctor that checks truth, not presence #44

Closed
admin wants to merge 1 commits from fix/security-headers-and-doctor-truth-20260907 into main
Owner

⛔ DO NOT MERGE — merging this deploys to production

.gitea/workflows/cd.yml triggers on push: branches: [main] and POSTs to
https://cloud.siax.io/api/v1/deploy?uuid=${COOLIFY_APP_ID}. Its guard step
only aborts when COOLIFY_APP_ID is still the literal placeholder or
COOLIFY_TOKEN is unset. Neither is true any more — verified against the
Gitea API on 2026-09-07:

GET /repos/sax3l/0pt/actions/variables COOLIFY_APP_ID = shxd6lepxuxfsjsp0ath3z1g (a real UUID, not the placeholder)
GET /repos/sax3l/0pt/actions/secrets COOLIFY_TOKEN present (created 2026-08-31)

So the guard passes and the deploy fires. Merging this PR is a production
deploy of 0pt onto Coolify. That decision is Simon's, not CI's. Please merge
deliberately, not because the checks are green.

Two side notes for whoever merges:

  • cd.yml's own header comment still says "no Coolify application has been
    provisioned for 0pt yet … COOLIFY_APP_ID below is a literal placeholder"
    .
    That comment is stale — the repo variable overrides it. .gitea/workflows/**
    is a protected path here, so this PR does not touch it.
  • This is also the first deploy where the headers below actually matter:
    0pt.app currently returns HTTP 402 DEPLOYMENT_DISABLED from Vercel, whose
    platform default was the only thing setting HSTS. On Coolify behind Traefik
    there is no such default.

What this fixes

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 security headers that did not exist

CHANGELOG.md has claimed CSP/HSTS/X-Frame-Options/etc. since 2026-08.
On main at 4514d97:

$ grep -rniE "content-security-policy|strict-transport-security|x-frame-options" . \
    --exclude-dir=.git --exclude-dir=node_modules -l
CHANGELOG.md

One file. Itself. No headers(), no middleware injection, no proxy config in
this repo. Live confirmation (2026-09-07 18:24 UTC):

$ curl -sSI https://0pt.app/api/health
HTTP/2 402
server: Vercel
x-vercel-error: DEPLOYMENT_DISABLED
strict-transport-security: max-age=63072000     <- Vercel's, not ours
(no content-security-policy, x-frame-options, x-content-type-options,
 referrer-policy or permissions-policy)

On the existing branch ci/security-headers-and-scanning (8d2ff73): its
next.config.mjs was the right idea, but it is 142 commits behind main, and
5 of its 8 changed files are under .gitea/workflows/**, which is a protected
path on this repo (protected_file_patterns: .gitea/workflows/**;.gitignore).
It could not be brought forward as-is. Its header work is carried forward here,
its workflow changes are not, and its carve-outs were re-derived against the
current tree rather than trusted:

  • it allowed form-action https://checkout.stripe.com https://api.stripe.com,
    justified by the Stripe Checkout hop. That hop is
    window.location.href = body.url in app/bestall/forskott/page.tsx — a
    top-level navigation, which form-action does not govern. The allowance was
    decorative, so it is dropped and form-action 'self' is used instead. The
    only <form> in the tree is onSubmit-only.
  • it did not account for components/embed.tsx, which renders a
    NocoDB/Metabase view in an <iframe>. A flat frame-src 'self' would blank
    that iframe with nothing but a devtools warning the day someone wires it up —
    exactly the failure sax3l/siax-io recorded as SIO-013 (its CSP silently
    blocked PostHog for months). frame-src is therefore derived from
    NOCODB_BASE_URL / METABASE_SITE_URL, the same env vars the feature is
    configured with, and the build-time-not-runtime caveat is written down in the
    file.
  • 'unsafe-eval' is added only under next dev, not unconditionally.

What lands, on /(.*), matching sax3l/siax-io's next.config.mjs idiom:

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline';
  style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:;
  connect-src 'self'; frame-src 'none'; frame-ancestors 'none'; form-action 'self';
  base-uri 'self'; object-src 'none'

plus poweredByHeader: false. preload is inert until 0pt.app is submitted at
hstspreload.org — a one-way account-holder decision that shipping this header
does not make.

B — scripts/siax-doctor.mjs validated presence, not truth

Its manifest checks were all requireJsonField, whose whole body is "does this
key exist"
. Reproduced on this tree with the pre-change script:

operationalState=down    -> exit 0 ; 40/40 checks passed.
operationalState=running -> exit 0 ; 40/40 checks passed.
$ diff old-down.txt old-running.txt
(no output — byte-identical)

That is why a descriptor asserting a running service survived while every one of
its hosts returned 402/404.

Design. Liveness is not knowable offline, and a gate that needs the public
internet fails for reasons unrelated to the change under review — people learn to
ignore those. So the split is:

  • Offline (default, and the only thing anything automated runs) — pure
    functions of the repo's own bytes:
    • values, not just keys: operationalState/lifecycle/category against
      the standard's enums, gitHost const, node against the fleet pattern,
      every fqdn a bare well-formed hostname.
    • internal consistency: every declared prod hostname must appear in this
      repo's own deploy/contract config (siax.config.json environments.*.domain,
      contracts/openapi.yaml servers:) and every configured domain must be
      declared back. A hostname nothing deploys is a hostname nobody owns.
    • substantiation (the one that closes the hole): operationalState must be
      matched by a dated probe record in evidence/operational-state.json that
      agrees with it. "running" requires every prod probe to be 2xx;
      "down" requires none to be. The record's ok flags are re-derived from
      httpStatus, so a hand-written ok: true next to a 402 fails too. A
      liveness claim older than 90 days stops counting as evidence (only for
      states that assert the service is up — an honest "down" does not decay
      into a lie by sitting still).
  • Live (--live, opt-in) — actually probes the declared hosts, compares
    reality to the declared state, and checks the headers on the wire.
    --write-evidence refreshes the record. Deliberately not in the CI path.

Proof it catches the exact historical defect, escalating through every way a
false claim could be pushed through:

what was faked result
operationalState: "running" in siax.repo.json only ❌ GOV-06 — evidence was recorded for "down"; exit 1
…plus the evidence file's own state flipped to "running" ❌ GOV-06 — "operationalState "running" but the recorded probes say otherwise: 0pt.app=402, api.0pt.app=404"; exit 1
…plus ok: false → ok: true by hand ❌ GOV-06 — "records ok=true next to httpStatus=402"; exit 1
…plus httpStatus forged to 200 (evidence fully fabricated) ✅ offline passes — stated plainly, this is the offline boundary — and ❌ --live catches it: "declared "running", live probe says: 0pt.app=402, api.0pt.app=404"; exit 1

tests/repo/siax-doctor.test.ts asserts those negative cases against fixture
trees (via a new SIAX_DOCTOR_ROOT env override) on every run, so "the check
cannot fail" cannot come back. It also runs the doctor itself inside
pnpm run test, which puts it in the blocking
CI (SIAX Cloud) / quality context without needing a workflow change.

C — the CHANGELOG now tells the truth

The header claim stands, because the headers landed. The four claims it was
bundled with were verified and are still false on main, so they are listed
as not-implemented rather than left asserted:

  • Trivy runs --exit-code 0 (advisory) in both security.yml and
    nightly.yml; --exit-code 1 was claimed.
  • gitleaks is not run anywhere in this repo.
  • Actions are not SHA-pinned (actions/checkout@v4, actions/setup-node@v4).
  • No SBOM and no license inventory are produced or uploaded.

All four live in .gitea/workflows/** and need an authorised workflow change.

Regression gates added

gate where proven to fail?
security headers present + strict tests/security/security-headers.test.ts yes — see below
siax-doctor green, and able to go red tests/repo/siax-doctor.test.ts yes — 8 negative fixtures

Both run under pnpm run test, i.e. the blocking quality job. No
.gitea/workflows/** change.

Proof the header gate fails, using the exact command CI runs:

# remove one line from next.config.mjs:  { key: "X-Frame-Options", value: "DENY" },
$ pnpm run test
 FAIL  tests/security/security-headers.test.ts > sets X-Frame-Options on every path
AssertionError: X-Frame-Options is not set for all paths -- CHANGELOG.md claims
0pt ships it. Either restore it in next.config.mjs or stop claiming it.
 Test Files  1 failed | 72 passed | 22 skipped (95)
exit 1
# weaken only a CSP directive:  frame-ancestors 'none' -> 'self'
$ pnpm run test
AssertionError: expected ''self'' to be ''none''
exit 1

Verification

command result
pnpm run typecheck exit 0
pnpm run test exit 0 — 74 files / 965 tests passed (was 72 / 945)
pnpm run build exit 0; .next/routes-manifest.json carries all six headers
node scripts/siax-doctor.mjs 55/55, exit 0
node scripts/siax-doctor.mjs --live 57/57, exit 0 (declared down matches the real 402/404)
npx eslint tests/security tests/repo exit 0 (repo lint is advisory; its 17 pre-existing problems are untouched)

Protected paths: nothing under .gitea/workflows/**, no .gitignore change —
confirmed by git status --porcelain.

🤖 Generated with Claude Code

## ⛔ DO NOT MERGE — merging this deploys to production `.gitea/workflows/cd.yml` triggers on `push: branches: [main]` and POSTs to `https://cloud.siax.io/api/v1/deploy?uuid=${COOLIFY_APP_ID}`. Its guard step only aborts when `COOLIFY_APP_ID` is still the literal placeholder or `COOLIFY_TOKEN` is unset. **Neither is true any more** — verified against the Gitea API on 2026-09-07: | | | |---|---| | `GET /repos/sax3l/0pt/actions/variables` | `COOLIFY_APP_ID = shxd6lepxuxfsjsp0ath3z1g` (a real UUID, not the placeholder) | | `GET /repos/sax3l/0pt/actions/secrets` | `COOLIFY_TOKEN` present (created 2026-08-31) | So the guard passes and the deploy fires. Merging this PR is a production deploy of 0pt onto Coolify. That decision is Simon's, not CI's. Please merge deliberately, not because the checks are green. Two side notes for whoever merges: * `cd.yml`'s own header comment still says *"no Coolify application has been provisioned for 0pt yet … COOLIFY_APP_ID below is a literal placeholder"*. That comment is stale — the repo variable overrides it. `.gitea/workflows/**` is a protected path here, so this PR does not touch it. * This is also the first deploy where the headers below actually matter: 0pt.app currently returns `HTTP 402 DEPLOYMENT_DISABLED` from Vercel, whose platform default was the only thing setting HSTS. On Coolify behind Traefik there is no such default. --- ## What this fixes 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 security headers that did not exist `CHANGELOG.md` has claimed `CSP/HSTS/X-Frame-Options/etc.` since 2026-08. On `main` at `4514d97`: ``` $ grep -rniE "content-security-policy|strict-transport-security|x-frame-options" . \ --exclude-dir=.git --exclude-dir=node_modules -l CHANGELOG.md ``` One file. Itself. No `headers()`, no middleware injection, no proxy config in this repo. Live confirmation (2026-09-07 18:24 UTC): ``` $ curl -sSI https://0pt.app/api/health HTTP/2 402 server: Vercel x-vercel-error: DEPLOYMENT_DISABLED strict-transport-security: max-age=63072000 <- Vercel's, not ours (no content-security-policy, x-frame-options, x-content-type-options, referrer-policy or permissions-policy) ``` **On the existing branch `ci/security-headers-and-scanning` (8d2ff73):** its `next.config.mjs` was the right idea, but it is 142 commits behind `main`, and 5 of its 8 changed files are under `.gitea/workflows/**`, which is a protected path on this repo (`protected_file_patterns: .gitea/workflows/**;.gitignore`). It could not be brought forward as-is. Its header work is carried forward here, its workflow changes are not, and its carve-outs were re-derived against the current tree rather than trusted: * it allowed `form-action https://checkout.stripe.com https://api.stripe.com`, justified by the Stripe Checkout hop. That hop is `window.location.href = body.url` in `app/bestall/forskott/page.tsx` — a top-level navigation, which `form-action` does not govern. The allowance was decorative, so it is dropped and `form-action 'self'` is used instead. The only `<form>` in the tree is `onSubmit`-only. * it did not account for `components/embed.tsx`, which renders a NocoDB/Metabase view in an `<iframe>`. A flat `frame-src 'self'` would blank that iframe with nothing but a devtools warning the day someone wires it up — exactly the failure `sax3l/siax-io` recorded as SIO-013 (its CSP silently blocked PostHog for months). `frame-src` is therefore derived from `NOCODB_BASE_URL` / `METABASE_SITE_URL`, the same env vars the feature is configured with, and the build-time-not-runtime caveat is written down in the file. * `'unsafe-eval'` is added **only** under `next dev`, not unconditionally. What lands, on `/(.*)`, matching `sax3l/siax-io`'s `next.config.mjs` idiom: ``` Strict-Transport-Security: max-age=63072000; includeSubDomains; preload X-Frame-Options: DENY X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=() Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-src 'none'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; object-src 'none' ``` plus `poweredByHeader: false`. `preload` is inert until `0pt.app` is submitted at hstspreload.org — a one-way account-holder decision that shipping this header does not make. ### B — `scripts/siax-doctor.mjs` validated presence, not truth Its manifest checks were all `requireJsonField`, whose whole body is *"does this key exist"*. Reproduced on this tree with the **pre-change** script: ``` operationalState=down -> exit 0 ; 40/40 checks passed. operationalState=running -> exit 0 ; 40/40 checks passed. $ diff old-down.txt old-running.txt (no output — byte-identical) ``` That is why a descriptor asserting a running service survived while every one of its hosts returned 402/404. **Design.** Liveness is not knowable offline, and a gate that needs the public internet fails for reasons unrelated to the change under review — people learn to ignore those. So the split is: * **Offline (default, and the only thing anything automated runs)** — pure functions of the repo's own bytes: * *values*, not just keys: `operationalState`/`lifecycle`/`category` against the standard's enums, `gitHost` const, `node` against the fleet pattern, every `fqdn` a bare well-formed hostname. * *internal consistency*: every declared **prod** hostname must appear in this repo's own deploy/contract config (`siax.config.json environments.*.domain`, `contracts/openapi.yaml servers:`) and every configured domain must be declared back. A hostname nothing deploys is a hostname nobody owns. * *substantiation* (the one that closes the hole): `operationalState` must be matched by a dated probe record in `evidence/operational-state.json` that **agrees** with it. `"running"` requires every prod probe to be 2xx; `"down"` requires none to be. The record's `ok` flags are re-derived from `httpStatus`, so a hand-written `ok: true` next to a 402 fails too. A liveness claim older than 90 days stops counting as evidence (only for states that assert the service is up — an honest `"down"` does not decay into a lie by sitting still). * **Live (`--live`, opt-in)** — actually probes the declared hosts, compares reality to the declared state, and checks the headers on the wire. `--write-evidence` refreshes the record. Deliberately **not** in the CI path. **Proof it catches the exact historical defect**, escalating through every way a false claim could be pushed through: | what was faked | result | |---|---| | `operationalState: "running"` in `siax.repo.json` only | ❌ `GOV-06` — evidence was recorded for `"down"`; exit 1 | | …plus the evidence file's own state flipped to `"running"` | ❌ `GOV-06` — *"operationalState "running" but the recorded probes say otherwise: 0pt.app=402, api.0pt.app=404"*; exit 1 | | …plus `ok: false` → `ok: true` by hand | ❌ `GOV-06` — *"records ok=true next to httpStatus=402"*; exit 1 | | …plus `httpStatus` forged to 200 (evidence fully fabricated) | ✅ offline passes — **stated plainly, this is the offline boundary** — and ❌ `--live` catches it: *"declared "running", live probe says: 0pt.app=402, api.0pt.app=404"*; exit 1 | `tests/repo/siax-doctor.test.ts` asserts those negative cases against fixture trees (via a new `SIAX_DOCTOR_ROOT` env override) on every run, so "the check cannot fail" cannot come back. It also runs the doctor itself inside `pnpm run test`, which puts it in the blocking **`CI (SIAX Cloud) / quality`** context without needing a workflow change. ### C — the CHANGELOG now tells the truth The header claim stands, because the headers landed. The four claims it was bundled with were verified and are **still false on `main`**, so they are listed as not-implemented rather than left asserted: * Trivy runs `--exit-code 0` (advisory) in both `security.yml` and `nightly.yml`; `--exit-code 1` was claimed. * gitleaks is not run anywhere in this repo. * Actions are not SHA-pinned (`actions/checkout@v4`, `actions/setup-node@v4`). * No SBOM and no license inventory are produced or uploaded. All four live in `.gitea/workflows/**` and need an authorised workflow change. ## Regression gates added | gate | where | proven to fail? | |---|---|---| | security headers present + strict | `tests/security/security-headers.test.ts` | yes — see below | | `siax-doctor` green, and able to go red | `tests/repo/siax-doctor.test.ts` | yes — 8 negative fixtures | Both run under `pnpm run test`, i.e. the blocking `quality` job. No `.gitea/workflows/**` change. **Proof the header gate fails, using the exact command CI runs:** ``` # remove one line from next.config.mjs: { key: "X-Frame-Options", value: "DENY" }, $ pnpm run test FAIL tests/security/security-headers.test.ts > sets X-Frame-Options on every path AssertionError: X-Frame-Options is not set for all paths -- CHANGELOG.md claims 0pt ships it. Either restore it in next.config.mjs or stop claiming it. Test Files 1 failed | 72 passed | 22 skipped (95) exit 1 ``` ``` # weaken only a CSP directive: frame-ancestors 'none' -> 'self' $ pnpm run test AssertionError: expected ''self'' to be ''none'' exit 1 ``` ## Verification | command | result | |---|---| | `pnpm run typecheck` | exit 0 | | `pnpm run test` | exit 0 — 74 files / 965 tests passed (was 72 / 945) | | `pnpm run build` | exit 0; `.next/routes-manifest.json` carries all six headers | | `node scripts/siax-doctor.mjs` | 55/55, exit 0 | | `node scripts/siax-doctor.mjs --live` | 57/57, exit 0 (declared `down` matches the real 402/404) | | `npx eslint tests/security tests/repo` | exit 0 (repo lint is advisory; its 17 pre-existing problems are untouched) | Protected paths: nothing under `.gitea/workflows/**`, no `.gitignore` change — confirmed by `git status --porcelain`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
admin added 1 commit 2026-09-07 18:47:26 +00:00
fix(security,repo-honesty): real security headers + a doctor that checks truth, not presence
CI (SIAX Cloud) / security (push) Successful in 14s
Security Scan (SIAX Cloud) / security (push) Successful in 14s
CI (SIAX Cloud) / integration (push) Failing after 30s
CI (SIAX Cloud) / quality (push) Successful in 47s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 14s
CI (SIAX Cloud) / integration (pull_request) Failing after 25s
CI (SIAX Cloud) / security (pull_request) Successful in 29s
CI (SIAX Cloud) / quality (pull_request) Successful in 48s
89a435f837
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>
CODEOWNERS rules requested review from sax3l 2026-09-07 18:47:26 +00:00
Author
Owner

Wire-level proof: the built container really serves them

next.config.mjs sets response headers; it does not prove they reach a client.
So the repo's own Dockerfile (the artifact Coolify builds — build_pack: dockerfile) was built and run, and the running container probed:

$ docker build -t 0pt-hdr-proof:local .        # exit 0
$ docker run -d -p 127.0.0.1:3998:3000 0pt-hdr-proof:local
$ docker exec 0pt-hdr-proof sh -c 'echo NODE_ENV=$NODE_ENV'
NODE_ENV=production

$ curl -sS -o /dev/null -D - http://127.0.0.1:3998/
HTTP/1.1 200 OK
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline';
  style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:;
  connect-src 'self'; frame-src 'none'; frame-ancestors 'none'; form-action 'self';
  base-uri 'self'; object-src 'none'
  • no X-Powered-By (poweredByHeader: false works),
  • no 'unsafe-eval' — the dev-only carve-out did not leak into the production
    image,
  • /api/health carries the same set, so API responses are covered too, not
    just HTML.

For contrast, the same probe against the live host right now:

$ curl -sSI https://0pt.app/api/health
HTTP/2 402
server: Vercel
x-vercel-error: DEPLOYMENT_DISABLED
strict-transport-security: max-age=63072000     <- Vercel's default, the only one

CI

CI (SIAX Cloud) / quality and CI (SIAX Cloud) / security — the two contexts
this branch's protection actually requires — are green on both the push and
the pull_request runs.

CI (SIAX Cloud) / integration is red, and was already red on main: run
13589 (the current main HEAD, "Merge pull request … AUD0 outbox …") fails at
the identical step, "Integration tests against LIVE OpenSearch + ClickHouse
(blocking)"
. That job runs only pnpm vitest run tests/integration, which this
PR does not touch. Pre-existing, not introduced here — but also not fixed here,
so it is worth its own look.

🤖 Generated with Claude Code

### Wire-level proof: the built container really serves them `next.config.mjs` sets response headers; it does not prove they reach a client. So the repo's own `Dockerfile` (the artifact Coolify builds — `build_pack: dockerfile`) was built and run, and the running container probed: ``` $ docker build -t 0pt-hdr-proof:local . # exit 0 $ docker run -d -p 127.0.0.1:3998:3000 0pt-hdr-proof:local $ docker exec 0pt-hdr-proof sh -c 'echo NODE_ENV=$NODE_ENV' NODE_ENV=production $ curl -sS -o /dev/null -D - http://127.0.0.1:3998/ HTTP/1.1 200 OK Strict-Transport-Security: max-age=63072000; includeSubDomains; preload X-Frame-Options: DENY X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=() Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-src 'none'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; object-src 'none' ``` * no `X-Powered-By` (`poweredByHeader: false` works), * no `'unsafe-eval'` — the dev-only carve-out did not leak into the production image, * `/api/health` carries the same set, so API responses are covered too, not just HTML. For contrast, the same probe against the live host right now: ``` $ curl -sSI https://0pt.app/api/health HTTP/2 402 server: Vercel x-vercel-error: DEPLOYMENT_DISABLED strict-transport-security: max-age=63072000 <- Vercel's default, the only one ``` ### CI `CI (SIAX Cloud) / quality` and `CI (SIAX Cloud) / security` — the two contexts this branch's protection actually requires — are **green** on both the push and the pull_request runs. `CI (SIAX Cloud) / integration` is **red, and was already red on `main`**: run `13589` (the current `main` HEAD, "Merge pull request … AUD0 outbox …") fails at the identical step, *"Integration tests against LIVE OpenSearch + ClickHouse (blocking)"*. That job runs only `pnpm vitest run tests/integration`, which this PR does not touch. Pre-existing, not introduced here — but also not fixed here, so it is worth its own look. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
admin closed this pull request 2026-09-15 00:50:40 +00:00
Author
Owner

OMSLUTEN av PR #56 (mergad): samma innehåll cherry-pickat (8ebc90f) med konfliktlösningar mot main (next.config.mjs standalone + headers bevarade båda, CHANGELOG honesty-resolution). Branchen fix/security-headers-and-doctor-truth-20260907 städas bort.

OMSLUTEN av PR #56 (mergad): samma innehåll cherry-pickat (8ebc90f) med konfliktlösningar mot main (next.config.mjs standalone + headers bevarade båda, CHANGELOG honesty-resolution). Branchen `fix/security-headers-and-doctor-truth-20260907` städas bort.

Pull request closed

This pull request cannot be reopened because the branch was deleted.
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/0pt#44