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.Functionremoved, and the pointer-encoding case. - Playwright: 43/44 in the full run; the one failure (
account.spec.tsadmin case) is the known load flake tracked in SCRUM-261, and the spec passed 15/15 with--repeat-each=3. curl -Iagainst the realnginx.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/admin301, 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 evalin 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.