From 44a777dc3febd543490a0f841bb23454bcbe7bc0 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 30 Oct 2026 21:32:56 +0100 Subject: [PATCH] =?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,