Set Cache-Control on every rendered page, and record the CSRF token's unbinding #178

Merged
lilleman merged 6 commits from item-15 into main 2026-10-30 21:39:31 +01:00
8 changed files with 73 additions and 23 deletions
+3
View File
@@ -221,6 +221,9 @@ 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`; README → Security
model): the usual cost of a signed double-submit token, covered by `SameSite=Lax` and HSTS.
**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.
+7
View File
@@ -12,6 +12,13 @@ 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. A route
whose page is the same for everyone can still set 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
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` keeps a cross-site page
out, and HSTS (`SECURE_COOKIES=true`) a network attacker. A hostile page on a sibling subdomain can
still set cookies, so host nothing untrusted on one.
**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.
@@ -1645,6 +1650,11 @@ Every response carries security headers (`src/http/security-headers.ts`): a stri
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`.
Every page and JSON response is `Cache-Control: private, no-store`, so a cache in front of `web`
never keeps one user's page for the next. A route may override it only for a page that is the same for
every visitor: no user, no CSRF token, no `Set-Cookie`, since the host adds a fresh CSRF cookie to
every plugin route.
A deep link reached while signed out — or after the session JWT lapses mid-task — bounces to the A deep link reached while signed out — or after the session JWT lapses mid-task — bounces to the
themed sign-in and returns to the **page that was requested** (`return_to`, validated host-relative themed sign-in and returns to the **page that was requested** (`return_to`, validated host-relative
by `localPath` in `src/http/safe-url.ts`, so a crafted value can't turn login completion into an open by `localPath` in `src/http/safe-url.ts`, so a crafted value can't turn login completion into an open
+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 });
+33
View File
@@ -284,6 +284,39 @@ 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: pages and JSON are private, no-store unless the route sets its own; assets are left alone", 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}=`));
// The signed-out landing is no-store as well: it and the signed-in page share one URL.
assert.equal((await fetch(base + "/")).headers.get("cache-control"), NO_STORE);
assert.equal((await fetch(base + "/", { headers: { cookie: csrf } })).headers.get("cache-control"), NO_STORE);
assert.equal((await fetch(base + "/", { headers: { cookie: csrf }, method: "HEAD" })).headers.get("cache-control"), NO_STORE);
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
assert.equal((await fetch(base + "/public/css/styles.css")).headers.get("cache-control"), null);
const demo: 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, [demo]);
for (const path of ["/demo/open", "/demo/data", "/"]) {
assert.equal((await fetch(url + path)).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 });
+7
View File
@@ -36,6 +36,8 @@ 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";
const NO_STORE = "private, no-store";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
export interface AppOptions { export interface AppOptions {
@@ -203,6 +205,11 @@ 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. Not
// even the signed-out `/` is shared: it and the signed-in page have one URL, and a cache keyed
// on URL and Accept-Language would hand the signed-out one to a signed-in visitor. A route that
// is the same for everyone sets its own value in RouteResult.headers; the host doesn't police it.
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
+11 -22
View File
@@ -6,13 +6,12 @@
`Bar = 1` `Bar = 1`
`Next ID = 33` `Next ID = 34`
## Items ## Items
| 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 |
@@ -35,6 +31,7 @@
| 1 | 0.4.21 | | owner | **Publish every release to Docker Hub, and give the Hub page its short description.** | 3 | 3 | 2 | 6 | 5 | 1 by 3 | 1.3 | | 1 | 0.4.21 | | owner | **Publish every release to Docker Hub, and give the Hub page its short description.** | 3 | 3 | 2 | 6 | 5 | 1 by 3 | 1.3 |
| 13 | 0.4.21 | | | **Accept a verification code pasted with surrounding whitespace.** | 4 | 2 | 3 | 7 | 7 | 2 by 8 | 1.0 | | 13 | 0.4.21 | | | **Accept a verification code pasted with surrounding whitespace.** | 4 | 2 | 3 | 7 | 7 | 2 by 8 | 1.0 |
| 19 | 0.5.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 | | 19 | 0.5.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
| 33 | 0.5.0 | | question | **Decide whether any public page gets a shared-cache `Cache-Control`, now that the host sends `private, no-store` on every page.** | 2 | 3 | 4 | 7 | 3 | 8 by 2 | 2.0 |
| 16 | 0.5.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 | | 16 | 0.5.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
| 6 | 0.5.0 | | | **Warn at discovery when a route or nav node gates on a permission no plugin declares.** | 2 | 1 | 1 | 2 | 2 | 7 by 3, 9 by 3 | 8.0 | | 6 | 0.5.0 | | | **Warn at discovery when a route or nav node gates on a permission no plugin declares.** | 2 | 1 | 1 | 2 | 2 | 7 by 3, 9 by 3 | 8.0 |
| 23 | 0.5.0 | | | **Give list pages one view-model helper from query to pagination, instead of ~40 hand-written lines each.** | 3 | 1 | 2 | 4 | 5 | 1 by 4, 9 by 2, code health by 3 | 7.9 | | 23 | 0.5.0 | | | **Give list pages one view-model helper from query to pagination, instead of ~40 hand-written lines each.** | 3 | 1 | 2 | 4 | 5 | 1 by 4, 9 by 2, code health by 3 | 7.9 |
@@ -45,12 +42,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 +76,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 +91,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
@@ -169,6 +149,15 @@ test covers. Code entry has no E2E coverage today.
Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is
`dialout` inside the noble image, and Docker Desktop remaps bind-mount ownership in its VM. `dialout` inside the noble image, and Docker Desktop remaps bind-mount ownership in its VM.
### 33. Decide whether any public page gets a shared-cache `Cache-Control`, now that the host sends `private, no-store` on every page.
Every non-asset response is `private, no-store`. The built-in signed-out `/` and its signed-in
variant share one URL, so a shared cache keyed on URL and `Accept-Language` would serve the
signed-out page to a signed-in visitor for the cache lifetime, and a cache that bypasses on cookies
would never store it. Options: leave it, so operators cache only assets; add `Vary: Cookie`; or give
the signed-out page its own URL. A plugin route can already opt in through `RouteResult.headers`.
I is 4 because a wrong rule caches a signed-in page for everyone, exposing PII.
### 16. Decide whether the single Keto `Resource` namespace splits into per-domain namespaces. ### 16. Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.
Ory's own examples model `Shift`, `Document` and so on. One global `Resource` bucket is the Ory's own examples model `Shift`, `Document` and so on. One global `Resource` bucket is the