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
+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+/);
});
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
View File
@@ -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