Skip to content

SCRUM-260: security headers on /admin/

Plan ref: UI-15 (docs/11-admin-plane-plan.md). Stacked on SCRUM-259 (UI).

What exists

Piece Notes
services/adminui/security-headers.conf copied to /etc/nginx/adminui-security-headers.conf; always on every header
CSP default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
Others X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: same-origin
Inheritance a location with its own add_header (the Cache-Control ones) drops the server-level headers, so the snippet is included at server level and in each of those locations; tests/unit/nginx-headers.spec.ts fails if one is missed
Local validation Ajv compiled schemas with new Function, which script-src 'self' blocks (found by loading the app under the header: the namespace editor's hints went "unavailable"). Replaced with @cfworker/json-schema 4.1.1 (interpreter, draft 2020-12, formats built in, MIT, no deps); ajv/ajv-formats removed. Same validateLocally API; wrapper errors are collapsed so pointers stay one leaf per location, and percent-encoded locations are decoded (/max hp, not /max%20hp)

Why style-src 'unsafe-inline': Ionic and CodeMirror inject <style> at runtime; inline styles cannot execute code, and script-src stays strict. img-src data:: the TOTP QR code and Ionic's built-in icons. A cross-origin apiBaseUrl in env.json would need adding to connect-src.

How to verify

cd services/adminui
npm run typecheck && npm run lint && npm run format:check && npx vitest run && npm run build
npx playwright test
# against the image:
curl -sI http://127.0.0.1:8090/admin/ | grep -iE 'content-security|x-frame|nosniff|referrer'

Results at time of writing

  • typecheck, lint, format:check, build: pass. vitest 501 passed (53 files), incl. the header policy spec, validation with globalThis.Function removed, and the pointer-encoding case.
  • Playwright: 43/44 in the full run; the one failure (account.spec.ts admin case) is the known load flake tracked in SCRUM-261, and the spec passed 15/15 with --repeat-each=3.
  • curl -I against the real nginx.conf + snippet (Ubuntu nginx 1.24, unpacked locally; the image runs 1.29): /admin/, /admin/index.html, /admin/env.json, a deep link, a hashed asset, the /admin 301, and both 404s all carry the four headers; Cache-Control is unchanged.
  • Browser under the CSP: a mock-mode build served by that nginx, driven by Chromium through sign-in, account (TOTP QR rendered), dashboard, service chart, logs, config, namespace editor, schema editor (CodeMirror styled), releases and packs: 0 CSP violations (1 before the validator swap: script-src eval in the namespace editor).

How it was built

Claude wrote the snippet, the nginx/dockerfile wiring and the browser check, and swapped the dependency (lockfile hand-trimmed to the real change). DeepSeek scoped (Landlock) to services/adminui, reasoning effort low, ported the validator and wrote the policy test and README section. Claude review found the percent-encoded pointers and fixed them. Docs 00 and 02 now name the new validator.