From 1dfc6aac3ab65d22f59fe231b6088c34f683397a Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 30 Oct 2026 21:28:14 +0100 Subject: [PATCH 1/6] Set Cache-Control on every rendered page, and record the CSRF token's unbinding --- AGENTS.md | 4 +++ CHANGELOG.md | 8 +++++ README.md | 11 +++++- e2e-tests/playwright.config.ts | 1 + examples/plugins/scheduling/views/shifts.ejs | 2 +- src/http/app.test.ts | 37 ++++++++++++++++++++ src/http/app.ts | 13 ++++++- todo.md | 21 ----------- 8 files changed, 73 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4e1dc86..92f2c58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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. +- **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 concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape — don't rename it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 682642a..d9623eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 **Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page diff --git a/README.md b/README.md index 73431d8..e4989c1 100644 --- a/README.md +++ b/README.md @@ -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 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 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 `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'`, -`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 header per-response via `RouteResult.headers`. diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index 3476741..f5b79ed 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ testDir: ".", outputDir: "artifacts/test-output", fullyParallel: true, + workers: 4, // Playwright's default is half the cores, so a run's parallelism would depend on the machine forbidOnly: true, reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]], use: { diff --git a/examples/plugins/scheduling/views/shifts.ejs b/examples/plugins/scheduling/views/shifts.ejs index e426f3b..708edf4 100644 --- a/examples/plugins/scheduling/views/shifts.ejs +++ b/examples/plugins/scheduling/views/shifts.ejs @@ -2,7 +2,7 @@ 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 (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? %><% const navHtml = include("partials/nav-tree", { nodes: chrome.nav }); diff --git a/src/http/app.test.ts b/src/http/app.test.ts index 5901282..0d3fc3a 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -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+/); }); +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: "

plugin home

" }), + 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. test("renders correctly with template caching enabled", async () => { const app = createApp({ cache: true }); diff --git a/src/http/app.ts b/src/http/app.ts index 6a65e9c..a9a05b0 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -36,6 +36,10 @@ import { localPath } from "./safe-url.ts"; import { routePublic, serveStatic } from "./static.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)), "..", ".."); export interface AppOptions { @@ -151,7 +155,10 @@ export function createApp(options: AppOptions = {}): Server { await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, homePlugin.id), pluginCtx.localeHref); 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 @@ -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 // the same bytes in every language, and a Vary there fragments its entry per raw header. 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, // the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the diff --git a/todo.md b/todo.md index c1af349..883aad2 100644 --- a/todo.md +++ b/todo.md @@ -12,7 +12,6 @@ | 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 | | 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 | @@ -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 | | 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 | -| 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 | | 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 | -| 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 | | 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 | @@ -45,12 +41,6 @@ ## 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. 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. 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 `.` 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. 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. 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. `flowPage` (`src/auth/routes.ts`) rethrows a 401, which Kratos answers to a settings-flow init with no -- 2.52.0 From 44a777dc3febd543490a0f841bb23454bcbe7bc0 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 30 Oct 2026 21:32:56 +0100 Subject: [PATCH 2/6] =?UTF-8?q?Answer=20stability=20round=201:=201=20?= =?UTF-8?q?=F0=9F=94=B4,=200=20=F0=9F=9F=A1=20(0=20misread);=20share=20no?= =?UTF-8?q?=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++---- src/http/app.test.ts | 18 +++++++----------- src/http/app.ts | 14 +++++--------- 3 files changed, 15 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9623eb..6cb066c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,9 @@ 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. +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 diff --git a/src/http/app.test.ts b/src/http/app.test.ts index 0d3fc3a..706c227 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -284,27 +284,23 @@ test("every response carries the security headers; HSTS follows SECURE_COOKIES", 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) => { +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}=`)); - // A first visit mints a per-visitor cookie, so that response must not be shared. + // 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); - // 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 } })).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 - // 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 = { + const demo: Plugin = { apiVersion: "1.0.0", home: () => ({ html: "

plugin home

" }), id: "demo", @@ -314,9 +310,9 @@ test("Cache-Control: every host response is private, no-store; only the anonymou { handler: () => ({ json: { ok: true } }), method: "GET", path: "/data", public: true }, ], }; - const url = await startApp(t, [shared]); + const url = await startApp(t, [demo]); 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 + path)).headers.get("cache-control"), NO_STORE, path); } assert.equal((await fetch(url + "/demo/same")).headers.get("cache-control"), "public, max-age=600"); }); diff --git a/src/http/app.ts b/src/http/app.ts index a9a05b0..b354c63 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -36,9 +36,7 @@ import { localPath } from "./safe-url.ts"; import { routePublic, serveStatic } from "./static.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)), "..", ".."); @@ -155,10 +153,7 @@ export function createApp(options: AppOptions = {}): Server { await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, homePlugin.id), pluginCtx.localeHref); return null; } - // 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" }; + return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" }; }; // "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in @@ -210,9 +205,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 // the same bytes in every language, and a Vary there fragments its entry per raw header. 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). + // 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, -- 2.52.0 From 3c5c0c911ce57b7da00c650d84f34f8ee461e8a6 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 30 Oct 2026 21:32:56 +0100 Subject: [PATCH 3/6] =?UTF-8?q?Answer=20product-owner=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=202=20=F0=9F=9F=A1=20(0=20misread);=20document=20?= =?UTF-8?q?when=20a=20route=20may=20override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e4989c1..9bcdd2c 100644 --- a/README.md +++ b/README.md @@ -1385,9 +1385,9 @@ so a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. 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. +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 within one token TTL, unless the [denylist](#instant-revoke-the-optional-denylist) is on. @@ -1646,14 +1646,15 @@ schedule, and rehearse the restore. 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), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` + `frame-ancestors 'none'`, -`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, +`Referrer-Policy`, and HSTS when `SECURE_COOKIES=true`. 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`. +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 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 -- 2.52.0 From 9370fb36f2d376459d566412c624e253c195cf0c Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 30 Oct 2026 21:32:56 +0100 Subject: [PATCH 4/6] =?UTF-8?q?Answer=20prose=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=205=20=F0=9F=9F=A1=20(0=20misread);=20tighten=20t?= =?UTF-8?q?he=20CSRF=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 92f2c58..1436ff9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -221,10 +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 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. -- **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.** +- **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 concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape — don't rename it. -- 2.52.0 From 070d745a4b3afc99bd60055132350485b61b2dc4 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 30 Oct 2026 21:33:34 +0100 Subject: [PATCH 5/6] =?UTF-8?q?Answer=20stability=20round=202:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=200=20=F0=9F=9F=A1=20(0=20misread);=20no=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -- 2.52.0 From 6f11a9928b1b3073efe7880dacf4fb6d85327758 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 30 Oct 2026 21:34:08 +0100 Subject: [PATCH 6/6] File the decision on caching public pages --- todo.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/todo.md b/todo.md index 883aad2..3aa7d27 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 1` -`Next ID = 33` +`Next ID = 34` ## Items @@ -31,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 | | 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 | +| 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 | | 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 | @@ -148,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 `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. Ory's own examples model `Shift`, `Document` and so on. One global `Resource` bucket is the -- 2.52.0