§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

@@ -52,9 +52,9 @@ only where the platform leaves a gap (see [AGENTS.md](AGENTS.md)).
> `config/menu.ts` override + branding), the **Ory stack** (Postgres, Kratos + the session→JWT > `config/menu.ts` override + branding), the **Ory stack** (Postgres, Kratos + the session→JWT
> tokenizer, Keto, Hydra), the **auth** wiring that consumes it (themed sign-in / register / reset / > tokenizer, Keto, Hydra), the **auth** wiring that consumes it (themed sign-in / register / reset /
> SSO, the session→JWT hot path, the users/groups/roles admin screens) and **Hydra's login / consent > SSO, the session→JWT hot path, the users/groups/roles admin screens) and **Hydra's login / consent
> / logout handlers** — all driven end-to-end by the Playwright suites. What's left is mainly > / logout handlers** — all driven end-to-end by the Playwright suites, plus **production & ops
> **production & ops hardening** (the prod compose profile, security headers, observability, a > hardening** (the prod compose profile, response security headers). What's left is mainly
> key-rotation runbook) — tracked in `todo.md` (§9). > **observability and a key-rotation runbook** — tracked in `todo.md` (§9).
## The MVP — "clone, one command, hack on a plugin" ## The MVP — "clone, one command, hack on a plugin"
@@ -578,6 +578,14 @@ Before going live, supply the production secrets and any SSO credentials — the
manual prep ([What you must supply](#what-you-must-supply-the-only-manual-prep)); the rest manual prep ([What you must supply](#what-you-must-supply-the-only-manual-prep)); the rest
is auto-generated. is auto-generated.
Every response carries security headers (`src/security-headers.ts`, set once per request): a
strict `Content-Security-Policy` (the core is **zero-JS**`script-src 'self'`, no inline
scripts, so an injected `<script>` can't run), `X-Content-Type-Options: nosniff`,
`X-Frame-Options: DENY` + `frame-ancestors 'none'`, `Referrer-Policy`, and — when
`SECURE_COOKIES=true` (https) — HSTS. The CSP allows **same-origin** assets only, so a branding
logo must live under `/public/` (or be a `data:` URI); a plugin route can override any header
per-response via `RouteResult.headers` (e.g. to ship its own JS).
The server drains in-flight requests on `SIGTERM`/`SIGINT` rather than cutting them The server drains in-flight requests on `SIGTERM`/`SIGINT` rather than cutting them
mid-response, so container restarts are clean. mid-response, so container restarts are clean.
@@ -602,6 +610,7 @@ src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer sign
src/bootstrap.ts One-command bootstrap (§3): idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto src/bootstrap.ts One-command bootstrap (§3): idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4) src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)
src/csrf.ts CSRF for our own POST forms (§4): signed double-submit token — issue/verify, cookie, request gate src/csrf.ts CSRF for our own POST forms (§4): signed double-submit token — issue/verify, cookie, request gate
src/security-headers.ts Response security headers set on every reply (§9): strict CSP (zero-JS), nosniff, X-Frame-Options/frame-ancestors, Referrer-Policy, HSTS over https
src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + §5 forms) src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + §5 forms)
src/context.ts RequestContext handed to handlers + buildContext() src/context.ts RequestContext handed to handlers + buildContext()
src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot

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); 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. // Production caches compiled templates; rendering must stay correct across repeated requests.
test("renders correctly with template caching enabled", async () => { test("renders correctly with template caching enabled", async () => {
const app = createApp({ cache: true }); const app = createApp({ cache: true });

View File

@@ -29,6 +29,7 @@ import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-c
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { Plugin, RouteResult } from "./plugin.ts"; import type { Plugin, RouteResult } from "./plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts"; import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
import { securityHeaders } from "./security-headers.ts";
import { routePublic, serveStatic } from "./static.ts"; import { routePublic, serveStatic } from "./static.ts";
import { renderPluginView } from "./view-resolver.ts"; import { renderPluginView } from "./view-resolver.ts";
@@ -72,6 +73,8 @@ export function createApp(options: AppOptions = {}): Server {
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR; const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
const publicDir = options.publicDir ?? join(rootDir, "public"); const publicDir = options.publicDir ?? join(rootDir, "public");
const viewsDir = options.viewsDir ?? join(rootDir, "views"); const viewsDir = options.viewsDir ?? join(rootDir, "views");
// Response security headers, fixed at boot (only HSTS depends on the https deployment signal).
const secHeaderEntries = Object.entries(securityHeaders({ secure: secureCookies }));
// `views: [viewsDir]` lets a view in a subfolder (e.g. admin/users.ejs) include() the shared // `views: [viewsDir]` lets a view in a subfolder (e.g. admin/users.ejs) include() the shared
// partials/ by the same root-relative name top-level views use (EJS tries relative first). // partials/ by the same root-relative name top-level views use (EJS tries relative first).
@@ -101,6 +104,10 @@ export function createApp(options: AppOptions = {}): Server {
const method = req.method ?? "GET"; const method = req.method ?? "GET";
const pathname = new URL(req.url ?? "/", "http://localhost").pathname; const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
// Set before any branch so every response — static/redirect/error included — inherits them
// (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
for (const [name, value] of secHeaderEntries) res.setHeader(name, value);
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) { if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
// /public/<id>/… serves a plugin's public/; everything else the core public/. // /public/<id>/… serves a plugin's public/; everything else the core public/.
// Before auth: assets don't need a verified user, and the JWT cookie rides every request. // Before auth: assets don't need a verified user, and the JWT cookie rides every request.

View File

@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { securityHeaders } from "./security-headers.ts";
test("securityHeaders: strict zero-JS defaults; HSTS only over https", () => {
const h = securityHeaders();
// Always-on hardening, independent of scheme.
assert.equal(h["x-content-type-options"], "nosniff");
assert.equal(h["x-frame-options"], "DENY");
assert.equal(h["referrer-policy"], "strict-origin-when-cross-origin");
assert.equal(h["cross-origin-opener-policy"], "same-origin");
const csp = h["content-security-policy"] ?? "";
assert.match(csp, /default-src 'self'/);
assert.match(csp, /script-src 'self'/); // a plugin may ship its own JS; the core ships none
assert.doesNotMatch(csp, /script-src[^;]*'unsafe-inline'/); // an injected <script> can't run
assert.match(csp, /style-src 'self' 'unsafe-inline'/); // a few partials use inline style= attrs
assert.match(csp, /frame-ancestors 'none'/); // clickjacking guard (modern X-Frame-Options)
assert.match(csp, /object-src 'none'/);
assert.doesNotMatch(csp, /form-action/); // omitted: the themed login posts to Kratos' (cross-origin) action
// No HSTS on the dev http origin…
assert.equal(h["strict-transport-security"], undefined);
// …but present once the deployment is https.
assert.match(securityHeaders({ secure: true })["strict-transport-security"] ?? "", /max-age=\d+; includeSubDomains/);
});

39
src/security-headers.ts Normal file
View File

@@ -0,0 +1,39 @@
// Response security headers (todo §9): set once per request in app.ts so every response — page,
// JSON, redirect, static, or error — carries them (writeHead merges with setHeader). A plugin route
// may override any of them per-response via RouteResult.headers (e.g. relax the CSP to ship its own JS).
// Strict default CSP for the zero-JS, server-rendered core:
// - script-src 'self' : the core ships no JS; a plugin may still serve its own /public/<id>/*.js for
// opt-in progressive enhancement. No 'unsafe-inline' ⇒ an injected <script>
// can't run (the main XSS sink).
// - style-src adds 'unsafe-inline' : a few partials carry inline style= attributes.
// - img-src adds data: : favicon + inline data URIs.
// - no form-action : the themed login form posts to Kratos' (often cross-origin) action URL.
// - frame-ancestors 'none' : clickjacking guard (the modern X-Frame-Options).
const CSP = [
"base-uri 'self'",
"default-src 'self'",
"frame-ancestors 'none'",
"img-src 'self' data:",
"object-src 'none'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
].join("; ");
export interface SecurityHeaderOptions {
secure?: boolean; // https deployment (mirrors SECURE_COOKIES) → also emit HSTS
}
// The header set applied to every response.
export function securityHeaders(options: SecurityHeaderOptions = {}): Record<string, string> {
const headers: Record<string, string> = {
"content-security-policy": CSP,
"cross-origin-opener-policy": "same-origin",
"referrer-policy": "strict-origin-when-cross-origin",
"x-content-type-options": "nosniff",
"x-frame-options": "DENY",
};
// HSTS only over https — ignored (and meaningless) on the dev http origin.
if (options.secure) headers["strict-transport-security"] = "max-age=31536000; includeSubDomains";
return headers;
}

View File

@@ -126,7 +126,7 @@ everything via Docker.
## 9. Production, security, ops ## 9. Production, security, ops
- [x] `compose.yml` prod: Ory + Postgres, secrets via env, no source mount. → The base file was already the full prod stack (web + Postgres + Kratos/Keto/Hydra + migrations + the one-shot bootstrap; `.:/app` lives only in the dev override), built during §3. **The real gap, now closed:** it set `REQUIRE_SECURE_SECRETS=true` but never wired `CSRF_SECRET` into `web`, so `docker compose -f compose.yml up` couldn't boot. Added `CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}` — env-supplied with the throwaway as the only fallback; `config.ts`'s existing `REQUIRE_SECURE_SECRETS` logic rejects that throwaway, so a forgotten prod secret **fails loud** (verified all three paths: prod-unset→reject, prod-set→real secret, dev→throwaway + toggle off → boots). Used `:-` not `:?` because compose interpolates the base file per-file *before* merging the override (confirmed empirically), so a `:?` in the base would also break the zero-config dev `docker compose up`. Tests-first: extended `compose.test.ts` (secret-via-env + no-source-mount + the prod/dev toggle split + postgres-creds-via-env). README prod section corrected (dropped the stale "_(… Ory + Postgres — planned)_"). typecheck + 310 units green. - [x] `compose.yml` prod: Ory + Postgres, secrets via env, no source mount. → The base file was already the full prod stack (web + Postgres + Kratos/Keto/Hydra + migrations + the one-shot bootstrap; `.:/app` lives only in the dev override), built during §3. **The real gap, now closed:** it set `REQUIRE_SECURE_SECRETS=true` but never wired `CSRF_SECRET` into `web`, so `docker compose -f compose.yml up` couldn't boot. Added `CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}` — env-supplied with the throwaway as the only fallback; `config.ts`'s existing `REQUIRE_SECURE_SECRETS` logic rejects that throwaway, so a forgotten prod secret **fails loud** (verified all three paths: prod-unset→reject, prod-set→real secret, dev→throwaway + toggle off → boots). Used `:-` not `:?` because compose interpolates the base file per-file *before* merging the override (confirmed empirically), so a `:?` in the base would also break the zero-config dev `docker compose up`. Tests-first: extended `compose.test.ts` (secret-via-env + no-source-mount + the prod/dev toggle split + postgres-creds-via-env). README prod section corrected (dropped the stale "_(… Ory + Postgres — planned)_"). typecheck + 310 units green.
- [ ] Security headers; secure/HttpOnly/SameSite cookies; CSRF; clock-skew tolerance. - [x] Security headers; secure/HttpOnly/SameSite cookies; CSRF; clock-skew tolerance. → Cookies (HttpOnly · SameSite=Lax · Secure-when-`SECURE_COOKIES`, `src/cookie.ts`), the signed double-submit CSRF (`src/csrf.ts`), and JWT clock-skew leeway (`JWT_CLOCK_SKEW_SEC`, applied to exp+nbf in `validateClaims`) all landed in §4 — 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 — `default-src 'self'`, `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.
- [ ] Optional revocation denylist for instant role/session revoke. - [ ] Optional revocation denylist for instant role/session revoke.
- [ ] Structured logging / basic observability. use @larvit/log for OTLP compability dig down in how to use it properly. - [ ] Structured logging / basic observability. use @larvit/log for OTLP compability dig down in how to use it properly.
- [ ] JWT signing-key rotation runbook. - [ ] JWT signing-key rotation runbook.