Answer stability round 1: 1 🔴, 0 🟡 (0 misread); share no page
This commit is contained in:
+3
-4
@@ -15,10 +15,9 @@ together.
|
|||||||
### Pages are not kept by a shared cache
|
### 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
|
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
|
proxy in front of Plainpages can't store a signed-in page and serve it to the next visitor. A route
|
||||||
built-in landing `/` is shared-cacheable (`public, max-age=0, s-maxage=60`) for a signed-out visitor
|
whose page is the same for everyone can still set its own `cache-control` through
|
||||||
who already holds the CSRF cookie. A plugin route whose page is the same for everyone sets its own
|
`RouteResult.headers`; assets are unchanged.
|
||||||
`cache-control` through `RouteResult.headers`; assets are unchanged.
|
|
||||||
|
|
||||||
## 0.4.0
|
## 0.4.0
|
||||||
|
|
||||||
|
|||||||
+7
-11
@@ -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+/);
|
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 NO_STORE = "private, no-store";
|
||||||
const csrf = (await fetch(base + "/")).headers.get("set-cookie")?.split(";")[0] ?? "";
|
const csrf = (await fetch(base + "/")).headers.get("set-cookie")?.split(";")[0] ?? "";
|
||||||
assert.match(csrf, new RegExp(`^${CSRF_COOKIE}=`));
|
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);
|
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"), NO_STORE);
|
||||||
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"), NO_STORE);
|
||||||
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 + "/", { 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", { 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 + "/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 + "/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);
|
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 demo: Plugin = {
|
||||||
const shared: Plugin = {
|
|
||||||
apiVersion: "1.0.0",
|
apiVersion: "1.0.0",
|
||||||
home: () => ({ html: "<p>plugin home</p>" }),
|
home: () => ({ html: "<p>plugin home</p>" }),
|
||||||
id: "demo",
|
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 },
|
{ 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", "/"]) {
|
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");
|
assert.equal((await fetch(url + "/demo/same")).headers.get("cache-control"), "public, max-age=600");
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-9
@@ -36,9 +36,7 @@ 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";
|
||||||
|
|
||||||
// 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 NO_STORE = "private, no-store";
|
||||||
const SHARED_PAGE = "public, max-age=0, s-maxage=60";
|
|
||||||
|
|
||||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
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);
|
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, homePlugin.id), pluginCtx.localeHref);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Shared only when nothing in the page or its headers belongs to this visitor: no user, and no
|
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
|
||||||
// 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
|
// "/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
|
// 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. A
|
// Fail closed: a cache in front of us must not keep a page that may carry a user's data. Not
|
||||||
// response that is the same for everyone opts in through its own headers (serveHome below, or
|
// even the signed-out `/` is shared: it and the signed-in page have one URL, and a cache keyed
|
||||||
// a plugin's RouteResult.headers).
|
// 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);
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user