Set Cache-Control on every rendered page, and record the CSRF token's unbinding
CI / full-gate (push) Successful in 3m23s

This commit is contained in:
2026-10-30 21:28:14 +01:00
parent 0c0a3bc905
commit 1dfc6aac3a
8 changed files with 73 additions and 24 deletions
+4
View File
@@ -221,6 +221,10 @@ Revisit only if the stated reason stops holding.
adds you to any group, including one holding every permission; `users:write` mints a recovery code adds you to any group, including one holding every permission; `users:write` mints a recovery code
for any account. The containment the split buys is real on the **read** half only (`users:read` is for any account. The containment the split buys is real on the **read** half only (`users:read` is
a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs. a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs.
- **The CSRF token is deliberately unbound to the session** (`src/auth/csrf.ts`): it is a signed
double-submit token, so an attacker who can write cookies on the origin can fix a token they know.
Accepted — standard for the unbound form, and `SameSite=Lax` plus HSTS cover it. **Valid while the
session cookie stays `SameSite=Lax` or stricter.**
- **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed - **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
Kratos' wire shape — don't rename it. Kratos' wire shape — don't rename it.
+8
View File
@@ -12,6 +12,14 @@ The topbar, filter bar, pager, form page and empty-table message inset their con
(12px), as table cells do, instead of 16px. A plugin that overrides `--pad-x` now moves all of them (12px), as table cells do, instead of 16px. A plugin that overrides `--pad-x` now moves all of them
together. together.
### Pages are not kept by a shared cache
Every page and JSON response now carries `Cache-Control: private, no-store`, so a cache or reverse
proxy in front of Plainpages can't store a signed-in page and serve it to the next visitor. The
built-in landing `/` is shared-cacheable (`public, max-age=0, s-maxage=60`) for a signed-out visitor
who already holds the CSRF cookie. A plugin route whose page is the same for everyone sets its own
`cache-control` through `RouteResult.headers`; assets are unchanged.
## 0.4.0 ## 0.4.0
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page **Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
+10 -1
View File
@@ -1384,6 +1384,11 @@ authorization graph, so treat it the same. `compose.yml` publishes none of the s
so a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our two cookies so a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our two cookies
obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config. obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
**The CSRF token isn't bound to the session.** It is a signed double-submit token, so an attacker who
can write cookies on your origin can plant one they know. `SameSite=Lax` and HSTS
(`includeSubDomains`) are what keep that attacker out; it holds while the session cookie stays
`SameSite=Lax` or stricter.
**Offboarding is not instant by default** — a revoked permission or deactivated identity lands **Offboarding is not instant by default** — a revoked permission or deactivated identity lands
within one token TTL, unless the [denylist](#instant-revoke-the-optional-denylist) is on. within one token TTL, unless the [denylist](#instant-revoke-the-optional-denylist) is on.
@@ -1641,7 +1646,11 @@ schedule, and rehearse the restore.
Every response carries security headers (`src/http/security-headers.ts`): a strict Every response carries security headers (`src/http/security-headers.ts`): a strict
`Content-Security-Policy` (the core is zero-JS — `script-src 'self'`, no inline scripts), `Content-Security-Policy` (the core is zero-JS — `script-src 'self'`, no inline scripts),
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` + `frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` + `frame-ancestors 'none'`,
`Referrer-Policy`, and HSTS when `SECURE_COOKIES=true`. The CSP allows **same-origin** assets only, `Referrer-Policy`, and HSTS when `SECURE_COOKIES=true`. A page or JSON response is
`Cache-Control: private, no-store`, so a shared cache never keeps one user's page for the next; only
the built-in landing `/`, for a visitor who is signed out and already holds the CSRF cookie, is
shared-cacheable. A route whose page is the same for everyone sets its own `cache-control` via
`RouteResult.headers`. 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 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`. header per-response via `RouteResult.headers`.
+1
View File
@@ -10,6 +10,7 @@ export default defineConfig({
testDir: ".", testDir: ".",
outputDir: "artifacts/test-output", outputDir: "artifacts/test-output",
fullyParallel: true, fullyParallel: true,
workers: 4, // Playwright's default is half the cores, so a run's parallelism would depend on the machine
forbidOnly: true, forbidOnly: true,
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]], reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
use: { use: {
+1 -1
View File
@@ -2,7 +2,7 @@
Scheduling · Shifts list (reference plugin). The handler fetched the rows from the upstream Scheduling · Shifts list (reference plugin). The handler fetched the rows from the upstream
service; this view renders them with the core building blocks inside the native app shell service; this view renders them with the core building blocks inside the native app shell
(ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table, (ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table,
alert) — see docs/plugin-contract.md. Zero-JS: search round-trips the URL. alert) — see the README. Zero-JS: search round-trips the URL.
Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error? Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error?
%><% %><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav }); const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
+37
View File
@@ -284,6 +284,43 @@ test("every response carries the security headers; HSTS follows SECURE_COOKIES",
assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/); assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/);
}); });
test("Cache-Control: every host response is private, no-store; only the anonymous built-in landing may be shared-cached", async (t) => {
const NO_STORE = "private, no-store";
const csrf = (await fetch(base + "/")).headers.get("set-cookie")?.split(";")[0] ?? "";
assert.match(csrf, new RegExp(`^${CSRF_COOKIE}=`));
// A first visit mints a per-visitor cookie, so that response must not be shared.
assert.equal((await fetch(base + "/")).headers.get("cache-control"), NO_STORE);
// The same page for an anonymous visitor who already holds the cookie is identical for all of them.
assert.equal((await fetch(base + "/", { headers: { cookie: csrf } })).headers.get("cache-control"), "public, max-age=0, s-maxage=60");
assert.equal((await fetch(base + "/", { headers: { cookie: csrf }, method: "HEAD" })).headers.get("cache-control"), "public, max-age=0, s-maxage=60");
// Signed in, the same URL carries that user's menu and sign-out form.
assert.equal((await fetch(base + "/", { headers: { cookie: `${csrf}; ${session()}` } })).headers.get("cache-control"), NO_STORE);
assert.equal((await fetch(base + "/dashboard", { headers: { cookie: session() } })).headers.get("cache-control"), NO_STORE);
assert.equal((await fetch(base + "/dashboard", { redirect: "manual" })).headers.get("cache-control"), NO_STORE); // sign-in bounce
assert.equal((await fetch(base + "/nope", { headers: { cookie: csrf } })).headers.get("cache-control"), NO_STORE); // 404
// An asset is the same bytes for everyone; its caching is not this header's job.
assert.equal((await fetch(base + "/public/css/styles.css")).headers.get("cache-control"), null);
// A plugin's pages and JSON default to no-store too, and a route that is the same for everyone opts in itself.
const shared: Plugin = {
apiVersion: "1.0.0",
home: () => ({ html: "<p>plugin home</p>" }),
id: "demo",
routes: [
{ handler: () => ({ html: "open" }), method: "GET", path: "/open", public: true },
{ handler: () => ({ headers: { "Cache-Control": "public, max-age=600" }, html: "same" }), method: "GET", path: "/same", public: true },
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data", public: true },
],
};
const url = await startApp(t, [shared]);
for (const path of ["/demo/open", "/demo/data", "/"]) {
assert.equal((await fetch(url + path, { headers: { cookie: csrf } })).headers.get("cache-control"), NO_STORE, path);
}
assert.equal((await fetch(url + "/demo/same")).headers.get("cache-control"), "public, max-age=600");
});
// 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 });
+12 -1
View File
@@ -36,6 +36,10 @@ import { localPath } from "./safe-url.ts";
import { routePublic, serveStatic } from "./static.ts"; import { routePublic, serveStatic } from "./static.ts";
import { renderPluginView } from "../plugin-host/view-resolver.ts"; import { renderPluginView } from "../plugin-host/view-resolver.ts";
// max-age=0 keeps a browser from showing a signed-out page after sign-in; only a shared cache keeps it.
const NO_STORE = "private, no-store";
const SHARED_PAGE = "public, max-age=0, s-maxage=60";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
export interface AppOptions { export interface AppOptions {
@@ -151,7 +155,10 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, homePlugin.id), pluginCtx.localeHref); await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, homePlugin.id), pluginCtx.localeHref);
return null; return null;
} }
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" }; // Shared only when nothing in the page or its headers belongs to this visitor: no user, and no
// Set-Cookie (a fresh CSRF cookie is per-visitor).
const sharable = !ctx.user && !ctx.res.hasHeader("set-cookie");
return { data: { chrome: ctx.chrome, user: ctx.user }, ...(sharable ? { headers: { "cache-control": SHARED_PAGE } } : {}), view: "home" };
}; };
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in // "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
@@ -203,6 +210,10 @@ export function createApp(options: AppOptions = {}): Server {
// A cache in front of us must key on the language. Set after the static branch: an asset is // A cache in front of us must key on the language. Set after the static branch: an asset is
// the same bytes in every language, and a Vary there fragments its entry per raw header. // the same bytes in every language, and a Vary there fragments its entry per raw header.
res.setHeader("vary", "accept-language"); res.setHeader("vary", "accept-language");
// Fail closed: a cache in front of us must not keep a page that may carry a user's data. A
// response that is the same for everyone opts in through its own headers (serveHome below, or
// a plugin's RouteResult.headers).
res.setHeader("cache-control", NO_STORE);
// Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser, // Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the // the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
-21
View File
@@ -12,7 +12,6 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|---|
| 15 | 0.4.21 | | security | **Set `Cache-Control` on every rendered page from the host: `private, no-store` on a gated or signed-in page, a cacheable value on a public one.** | 2 | 2 | 2 | 4 | 3 | 8 by 3 | 3.0 |
| 30 | 0.4.21 | | defect | **Give every `oklch()` colour a fallback, so primary buttons stay readable on an engine without it.** | 1 | 2 | 2 | 3 | 2 | 4 by 5 | 29.9 | | 30 | 0.4.21 | | defect | **Give every `oklch()` colour a fallback, so primary buttons stay readable on an engine without it.** | 1 | 2 | 2 | 3 | 2 | 4 by 5 | 29.9 |
| 29 | 0.4.21 | | defect | **Show the whole nav tree on an engine without `:has()`, and fold it only where `:has()` works.** | 2 | 2 | 2 | 4 | 3 | 4 by 6 | 26.9 | | 29 | 0.4.21 | | defect | **Show the whole nav tree on an engine without `:has()`, and fold it only where `:has()` works.** | 2 | 2 | 2 | 4 | 3 | 4 by 6 | 26.9 |
| 31 | 0.4.21 | | | **Delete the design-preview rules in `public/css/auth.css` that no view uses.** | 1 | 1 | 1 | 1 | 2 | code health by 2, 8 by 1 | 13.3 | | 31 | 0.4.21 | | | **Delete the design-preview rules in `public/css/auth.css` that no view uses.** | 1 | 1 | 1 | 1 | 2 | code health by 2, 8 by 1 | 13.3 |
@@ -21,12 +20,9 @@
| 9 | 0.4.21 | | | **Typecheck `e2e-tests/` in the gate.** | 1 | 1 | 1 | 1 | 3 | code health by 2 | 9.0 | | 9 | 0.4.21 | | | **Typecheck `e2e-tests/` in the gate.** | 1 | 1 | 1 | 1 | 3 | code health by 2 | 9.0 |
| 24 | 0.4.21 | | | **Retire `src/ui/shell-context.ts` and render the dashboard from `ctx.chrome`.** | 2 | 1 | 2 | 3 | 4 | code health by 3 | 9.0 | | 24 | 0.4.21 | | | **Retire `src/ui/shell-context.ts` and render the dashboard from `ctx.chrome`.** | 2 | 1 | 2 | 3 | 4 | code health by 3 | 9.0 |
| 17 | 0.4.21 | | | **Remove the icons no caller uses from `ICON_NAMES` and the sprite.** | 1 | 1 | 1 | 1 | 1 | 8 by 1, code health by 1 | 8.8 | | 17 | 0.4.21 | | | **Remove the icons no caller uses from `ICON_NAMES` and the sprite.** | 1 | 1 | 1 | 1 | 1 | 8 by 1, code health by 1 | 8.8 |
| 10 | 0.4.21 | | | **Pin Playwright's `workers` to a fixed number.** | 1 | 1 | 1 | 1 | 1 | code health by 1 | 7.2 |
| 18 | 0.4.21 | | | **Record in AGENTS.md and README → Security model that the CSRF token is deliberately unbound to the session, and why.** | 1 | 1 | 1 | 1 | 1 | code health by 1 | 7.2 |
| 21 | 0.4.21 | | | **Fail the gate on a function that exceeds a size or complexity limit.** | 3 | 1 | 1 | 3 | 5 | code health by 4 | 7.2 | | 21 | 0.4.21 | | | **Fail the gate on a function that exceeds a size or complexity limit.** | 3 | 1 | 1 | 3 | 5 | code health by 4 | 7.2 |
| 32 | 0.4.21 | 29, 30 | | **Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.** | 3 | 1 | 1 | 3 | 4 | code health by 2 | 6.0 | | 32 | 0.4.21 | 29, 30 | | **Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.** | 3 | 1 | 1 | 3 | 4 | code health by 2 | 6.0 |
| 27 | 0.4.21 | | | **Inset the scheduling example's lead text, shift count, alerts and call-to-action by `--pad-x`, like the bars around them.** | 1 | 2 | 1 | 2 | 1 | 9 by 2, 2 by 1 | 4.8 | | 27 | 0.4.21 | | | **Inset the scheduling example's lead text, shift count, alerts and call-to-action by `--pad-x`, like the bars around them.** | 1 | 2 | 1 | 2 | 1 | 9 by 2, 2 by 1 | 4.8 |
| 25 | 0.4.21 | | | **Point the views' references to `docs/plugin-contract.md` at the README.** | 1 | 2 | 1 | 2 | 1 | 9 by 3 | 4.6 |
| 20 | 0.4.21 | | | **Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.** | 2 | 2 | 2 | 4 | 3 | 2 by 4 | 4.2 | | 20 | 0.4.21 | | | **Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.** | 2 | 2 | 2 | 4 | 3 | 2 by 4 | 4.2 |
| 4 | 0.4.21 | | | **Refuse a permission save whose baseline no longer matches Keto, so concurrent edits are not silently lost.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 | | 4 | 0.4.21 | | | **Refuse a permission save whose baseline no longer matches Keto, so concurrent edits are not silently lost.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 |
| 7 | 0.4.21 | | | **Confirm a successful permission save, and report a partial failure instead of hiding it.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 | | 7 | 0.4.21 | | | **Confirm a successful permission save, and report a partial failure instead of hiding it.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 |
@@ -45,12 +41,6 @@
## Details ## Details
### 15. Set `Cache-Control` on every rendered page from the host: `private, no-store` on a gated or signed-in page, a cacheable value on a public one.
The host owns it, not the reverse proxy. Today responses carry `Vary: Accept-Language` but no
`Cache-Control`, so a shared cache in front of `web` may store a signed-in page, with that user's
data, and serve it to the next visitor.
### 30. Give every `oklch()` colour a fallback, so primary buttons stay readable on an engine without it. ### 30. Give every `oklch()` colour a fallback, so primary buttons stay readable on an engine without it.
The accent, focus and status tokens in `public/css/styles.css` are `oklch()`, which needs Chromium The accent, focus and status tokens in `public/css/styles.css` are `oklch()`, which needs Chromium
@@ -85,13 +75,6 @@ almost every field. Keep `shellUser` as the shared primitive.
`i-chart`, `i-copy` and `i-download` have no caller, and the sprite is inlined into every page. `i-chart`, `i-copy` and `i-download` have no caller, and the sprite is inlined into every page.
AGENTS.md → "`ICON_NAMES` is a host-owned registry" already makes this routine cleanup. AGENTS.md → "`ICON_NAMES` is a host-owned registry" already makes this routine cleanup.
### 18. Record in AGENTS.md and README → Security model that the CSRF token is deliberately unbound to the session, and why.
`src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so an attacker who
can write cookies on the origin can fix a token they know. Accepted: it is standard for unbound
signed double-submit, and `SameSite=Lax` plus HSTS cover it. Valid while the session cookie stays
`SameSite=Lax` or stricter.
### 21. Fail the gate on a function that exceeds a size or complexity limit. ### 21. Fail the gate on a function that exceeds a size or complexity limit.
Only `tsc --strict` runs today. A new tool is a new dependency, so justify it. Only `tsc --strict` runs today. A new tool is a new dependency, so justify it.
@@ -107,10 +90,6 @@ allow a feature only inside an `@supports` that guards it.
They sit flush against the content pane's edge while the filter bar, table and pager sit 12px in. They sit flush against the content pane's edge while the filter bar, table and pager sit 12px in.
The fix belongs in `examples/plugins/scheduling/public/scheduling.css`; check each page in a browser. The fix belongs in `examples/plugins/scheduling/public/scheduling.css`; check each page in a browser.
### 25. Point the views' references to `docs/plugin-contract.md` at the README.
`examples/plugins/scheduling/views/shifts.ejs` still cites it.
### 20. Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500. ### 20. Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.
`flowPage` (`src/auth/routes.ts`) rethrows a 401, which Kratos answers to a settings-flow init with no `flowPage` (`src/auth/routes.ts`) rethrows a 401, which Kratos answers to a settings-flow init with no