Set Cache-Control on every rendered page, and record the CSRF token's unbinding
CI / full-gate (push) Successful in 3m23s
CI / full-gate (push) Successful in 3m23s
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: "<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.
|
||||
test("renders correctly with template caching enabled", async () => {
|
||||
const app = createApp({ cache: true });
|
||||
|
||||
+12
-1
@@ -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
|
||||
|
||||
@@ -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 `<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.
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user