§9 response security headers (todo §9); the cookies/CSRF/clock-skew parts of this item all landed in §4 (HttpOnly/SameSite/Secure cookies in cookie.ts, the signed double-submit in csrf.ts, JWT_CLOCK_SKEW_SEC leeway on exp+nbf in jwt-middleware) — the open gap was response security headers, now closed. New pure src/security-headers.ts (securityHeaders({secure})): a strict CSP for the zero-JS core — script-src 'self' with NO 'unsafe-inline' (an injected <script> can't run; core ships none, a plugin may still serve its own /public/<id>/*.js), style-src adds 'unsafe-inline' for the partials' inline style=, img-src 'self' data:, frame-ancestors 'none', object-src 'none'; form-action deliberately omitted (the themed login POSTs to Kratos' often-cross-origin action URL) — plus X-Content-Type-Options nosniff, X-Frame-Options DENY, Referrer-Policy strict-origin-when-cross-origin, Cross-Origin-Opener-Policy same-origin, and HSTS only when secureCookies (https; ignored on dev http). Wired in app.ts: precomputed once at boot, res.setHeader'd at the very top of the handler before any branch, so every response (page/json/redirect/static/error/plugin) inherits them via writeHead's merge; a plugin overrides per-route via RouteResult.headers. Verified no view/CSS loads cross-origin (no <script> anywhere, no external fonts/CDNs), so default-src 'self' breaks nothing. Tests-first: security-headers.test.ts (strict defaults, script-src has no 'unsafe-inline', HSTS-only-on-secure) + an app.test.ts integration (the page and a static asset both carry the headers; HSTS toggles with SECURE_COOKIES). Stability-reviewer on the diff: APPROVE, no Critical/High (Low: a CDN/absolute branding logo would be CSP-blocked → documented the same-origin-logo constraint). README Status + Production + Layout updated. typecheck + 312 units green.

This commit is contained in:
2026-06-20 01:18:24 +02:00
parent b3b51db52b
commit 9d22c75016
6 changed files with 104 additions and 4 deletions

View File

@@ -82,6 +82,25 @@ test("static serving: GET sends body + content-type, HEAD headers only, unsafe p
assert.equal((await fetch(base + "/public/%00")).status, 403);
});
test("every response carries the security headers; HSTS follows SECURE_COOKIES (§9)", async (t) => {
// Default app (secureCookies off): a page and a static asset both carry the hardening headers,
// proving they're set once up front and survive each writeHead (the html + static paths merge).
for (const path of ["/", "/public/css/styles.css"]) {
const res = await fetch(base + path);
assert.equal(res.headers.get("x-content-type-options"), "nosniff", path);
assert.equal(res.headers.get("x-frame-options"), "DENY", path);
assert.match(res.headers.get("content-security-policy") ?? "", /default-src 'self'/, path);
assert.equal(res.headers.get("strict-transport-security"), null, path); // http dev → no HSTS
}
// A https deployment (SECURE_COOKIES=true) adds HSTS.
const secure = createApp({ secureCookies: true });
await new Promise<void>((r) => secure.listen(0, r));
t.after(() => secure.close());
const res = await fetch(`http://localhost:${(secure.address() as AddressInfo).port}/`);
assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/);
});
// Production caches compiled templates; rendering must stay correct across repeated requests.
test("renders correctly with template caching enabled", async () => {
const app = createApp({ cache: true });