Skip to content

Commit 450ea81

Browse files
authored
fix(preview): opt preview and toolbar responses out of the route cache (#1855)
* fix(preview): opt preview and toolbar responses out of the route cache Cache-Control: private, no-store only governs browsers and downstream proxies. With route caching enabled, the shared edge cache (Workers Cache) follows the route-cache options instead, so a valid preview response was stored in the shared cache and served on hits without any token verification — draft content stayed reachable until TTL/purge, even after token expiry. Toolbar-injected editor HTML had the same gap. Verified empirically on a stock Cloudflare deploy: preview URL request 1 was MISS with private, no-store, requests 2+ were shared-cache HITs. The middleware now calls context.cache.set(false) for any request carrying a _preview param and whenever the editor toolbar is injected. Read defensively so Astro versions without route caching are unaffected. * refactor(preview): use the typed route-cache API directly EmDash requires Astro 6+, which always provides context.cache, so the structural RouteCacheLike interface, the type assertion, and the optional call were unnecessary hedging. Type the helper against APIContext["cache"] and drop the "absent cache" test.
1 parent 1061a18 commit 450ea81

4 files changed

Lines changed: 142 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Fixes preview and editor-toolbar responses being stored in the shared edge cache when route caching is enabled: requests with a `_preview` token and toolbar-injected editor pages now opt out of the route cache, so draft content is no longer served from the cache without token verification and toolbar markup no longer leaks to anonymous visitors.

packages/core/src/astro/middleware/request-context.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* - Toolbar injection: floating pill for authenticated editors
1111
*/
1212

13+
import type { APIContext } from "astro";
1314
import { defineMiddleware } from "astro:middleware";
1415

1516
import { resolveSecretsCached } from "#config/secrets.js";
@@ -18,11 +19,31 @@ import { verifyPreviewToken, parseContentId } from "../../preview/tokens.js";
1819
import { getRequestContext, runWithContext } from "../../request-context.js";
1920
import { renderToolbar } from "../../visual-editing/toolbar.js";
2021

22+
/** Astro's route-cache handle. EmDash requires Astro 6+, so it's always present. */
23+
type RouteCache = APIContext["cache"];
24+
25+
/**
26+
* Opt the current request out of Astro's route cache (e.g. Workers Cache on
27+
* Cloudflare). `Cache-Control` headers do NOT cover this: the adapter derives
28+
* the shared-cache TTL from the route-cache options (on Cloudflare via
29+
* `Cloudflare-CDN-Cache-Control`), so session-specific responses must
30+
* explicitly disable it or they get stored in the shared cache and served to
31+
* anonymous visitors without ever invoking the middleware again. With no cache
32+
* provider configured this is a no-op (`NoopAstroCache`/`DisabledAstroCache`).
33+
*/
34+
function optOutOfRouteCache(cache: RouteCache): void {
35+
cache.set(false);
36+
}
37+
2138
/**
2239
* Inject toolbar HTML into a response if it's an HTML page.
2340
* Returns the original response if not HTML.
2441
*/
25-
async function injectToolbar(response: Response, toolbarHtml: string): Promise<Response> {
42+
async function injectToolbar(
43+
response: Response,
44+
toolbarHtml: string,
45+
routeCache: RouteCache,
46+
): Promise<Response> {
2647
const contentType = response.headers.get("content-type");
2748
if (!contentType?.includes("text/html")) return response;
2849

@@ -37,7 +58,10 @@ async function injectToolbar(response: Response, toolbarHtml: string): Promise<R
3758
// Toolbar-injected HTML is session-specific (its presence reveals an active
3859
// editor session); it must never be stored in a shared CDN cache and served
3960
// to anonymous visitors. Mirrors the preview branch's guard (#1398).
61+
// `Cache-Control` covers browsers/downstream proxies; the route-cache
62+
// opt-out covers the shared edge cache, which ignores `Cache-Control`.
4063
result.headers.set("Cache-Control", "private, no-store");
64+
optOutOfRouteCache(routeCache);
4165
return result;
4266
}
4367

@@ -86,6 +110,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
86110
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- Astro context includes currentLocale when i18n is configured
87111
const locale = (context as { currentLocale?: string }).currentLocale;
88112

113+
const routeCache = context.cache;
114+
89115
// Verify preview token if present.
90116
// The preview secret is resolved via `resolveSecretsCached`: env wins,
91117
// otherwise a DB-stored value is read (or generated on first need).
@@ -122,6 +148,15 @@ export const onRequest = defineMiddleware(async (context, next) => {
122148

123149
// Preview responses must not be cached -- draft content could leak past token expiry.
124150
// Clone the response before modifying headers — the original may be immutable.
151+
// `Cache-Control` only governs browsers/downstream proxies; the shared
152+
// edge cache follows the route-cache options, so opt out of those too —
153+
// otherwise the draft response is stored in the shared cache and served
154+
// on cache hits without token verification until TTL/purge. Opt out for
155+
// any request carrying a `_preview` param (valid or not): those URLs are
156+
// per-token, so cached copies are useless at best and drafts at worst.
157+
if (hasPreviewToken) {
158+
optOutOfRouteCache(routeCache);
159+
}
125160
if (preview) {
126161
response = new Response(response.body, response);
127162
response.headers.set("Cache-Control", "private, no-store");
@@ -133,7 +168,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
133168
editMode,
134169
isPreview: !!preview,
135170
});
136-
return injectToolbar(response, toolbarHtml);
171+
return injectToolbar(response, toolbarHtml, routeCache);
137172
}
138173

139174
return response;
@@ -147,7 +182,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
147182
editMode: false,
148183
isPreview: false,
149184
});
150-
return injectToolbar(response, toolbarHtml);
185+
return injectToolbar(response, toolbarHtml, routeCache);
151186
}
152187

153188
return next();
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Regression tests: preview and toolbar responses must opt out of Astro's
3+
* route cache, not just set `Cache-Control`.
4+
*
5+
* `Cache-Control: private, no-store` governs browsers and downstream
6+
* proxies, but the shared edge cache (e.g. Workers Cache on Cloudflare)
7+
* follows the route-cache options — on Cloudflare the adapter emits
8+
* `Cloudflare-CDN-Cache-Control` from them. Verified on a stock deploy:
9+
* a valid preview URL was a MISS on request 1 and a shared-cache HIT on
10+
* requests 2+, i.e. draft content was served without any token
11+
* verification until TTL/purge, even after token expiry.
12+
*
13+
* The fix calls `context.cache.set(false)` for requests carrying a
14+
* `_preview` param and for toolbar-injected editor responses.
15+
*/
16+
import { describe, it, expect, vi } from "vitest";
17+
18+
vi.mock("astro:middleware", () => ({
19+
defineMiddleware: (handler: unknown) => handler,
20+
}));
21+
22+
import onRequest from "../../../src/astro/middleware/request-context.js";
23+
24+
function buildContext(opts: {
25+
pathname?: string;
26+
search?: string;
27+
user?: { id: string; role: number } | null;
28+
editCookie?: boolean;
29+
cache: { set: (input: unknown) => void };
30+
}) {
31+
const url = new URL(`https://example.com${opts.pathname ?? "/blog"}${opts.search ?? ""}`);
32+
return {
33+
request: new Request(url),
34+
url,
35+
cookies: {
36+
get: vi.fn((name: string) =>
37+
name === "emdash-edit-mode" && opts.editCookie ? { value: "true" } : undefined,
38+
),
39+
set: vi.fn(),
40+
},
41+
locals: { user: opts.user ?? null },
42+
cache: opts.cache,
43+
} as unknown as Parameters<typeof onRequest>[0];
44+
}
45+
46+
const htmlResponse = () =>
47+
new Response("<html><body>hello</body></html>", {
48+
headers: { "content-type": "text/html" },
49+
});
50+
51+
describe("route-cache opt-out for preview and toolbar responses", () => {
52+
it("opts out of the route cache when a _preview param is present", async () => {
53+
const cache = { set: vi.fn() };
54+
// No emdash.db on locals — token can't be verified, but the response
55+
// still must not be stored under the per-token URL.
56+
const context = buildContext({ search: "?_preview=some-token", cache });
57+
58+
await onRequest(context, async () => htmlResponse());
59+
60+
expect(cache.set).toHaveBeenCalledWith(false);
61+
});
62+
63+
it("opts out of the route cache when the toolbar is injected", async () => {
64+
const cache = { set: vi.fn() };
65+
const context = buildContext({ user: { id: "u1", role: 30 }, cache });
66+
67+
const response = await onRequest(context, async () => htmlResponse());
68+
69+
// Confirm we're on the actual-injection path.
70+
expect(await response.text()).toContain('id="emdash-toolbar"');
71+
expect(cache.set).toHaveBeenCalledWith(false);
72+
});
73+
74+
it("leaves the route cache alone for editor requests without injection (non-HTML)", async () => {
75+
const cache = { set: vi.fn() };
76+
const context = buildContext({
77+
pathname: "/api/data.json",
78+
user: { id: "u1", role: 30 },
79+
cache,
80+
});
81+
82+
await onRequest(
83+
context,
84+
async () => new Response('{"ok":true}', { headers: { "content-type": "application/json" } }),
85+
);
86+
87+
expect(cache.set).not.toHaveBeenCalled();
88+
});
89+
90+
it("leaves the route cache alone for anonymous requests without CMS signals", async () => {
91+
const cache = { set: vi.fn() };
92+
const context = buildContext({ cache });
93+
94+
await onRequest(context, async () => htmlResponse());
95+
96+
expect(cache.set).not.toHaveBeenCalled();
97+
});
98+
});

packages/core/tests/unit/astro/request-context-toolbar-cache.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ function editorRequestContext(pathname = "/blog") {
2424
url: new URL(`https://example.com${pathname}`),
2525
cookies: { get: vi.fn(() => undefined), set: vi.fn() },
2626
locals: { user: { id: "u1", role: 30 } },
27+
cache: { set: vi.fn() },
2728
} as unknown as Parameters<typeof onRequest>[0];
2829
}
2930

0 commit comments

Comments
 (0)