Skip to content

fix app router cache request leaks#907

Open
NathanDrake2406 wants to merge 1 commit intocloudflare:mainfrom
NathanDrake2406:nathan/cache-isolation-fixes
Open

fix app router cache request leaks#907
NathanDrake2406 wants to merge 1 commit intocloudflare:mainfrom
NathanDrake2406:nathan/cache-isolation-fixes

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

What

Fixes two App Router cache-safety issues around request-specific data:

  • Enforces dynamic = "force-static" / dynamic = "error" for route-handler request APIs by stubbing or throwing for direct NextRequest fields and next/headers.
  • Tracks request.ip / request.geo as dynamic in auto mode.
  • Re-checks dynamic usage after HTML stream consumption before writing ISR HTML/RSC cache entries.

Why

Route handlers could previously expose request-specific IP/geo data through untracked fields, and HTML ISR could cache page output before lazy streamed Server Components had finished touching cookies() / headers(). Both cases could put per-request output into shared cache.

Validation

  • vp check
  • vp test run tests/app-route-handler-runtime.test.ts tests/app-route-handler-execution.test.ts tests/app-route-handler-cache.test.ts tests/app-route-handler-policy.test.ts tests/app-page-cache.test.ts tests/app-page-render.test.ts tests/app-page-response.test.ts tests/app-page-probe.test.ts tests/shims.test.ts
  • vp test run tests/app-router.test.ts
  • vp run vinext#build

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 26, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@907

commit: 1419580

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/cache-isolation-fixes branch 2 times, most recently from 88ec5af to 69e11ff Compare April 26, 2026 07:09
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review April 26, 2026 07:12
Copilot AI review requested due to automatic review settings April 26, 2026 07:12
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 26, 2026

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid pass at three real cache-leak risks. The runtime-proxy work for force-static / error route handlers tracks Next.js's forceStaticRequestHandlers and requireStaticRequestHandlers closely (including the localhost:3000 quirk in cleanURL), and the forceStatic flag on HeadersContext is a clean way to short-circuit markDynamicUsage for stubbed APIs.

A few things worth addressing before merging:

1. The HTML cache-write skip plugs the internal ISR write but not the response cache headers. resolveAppPageHtmlResponsePolicy runs against the eager dynamicUsedDuringRender (currently app-page-render.ts:278) — i.e. before the HTML stream is consumed. When a lazy server component touches cookies() / headers() mid-stream, the new check in app-page-cache.ts:252 correctly skips the ISR write, but the response we already returned to the client carries Cache-Control: s-maxage=N, stale-while-revalidate and X-Vinext-Cache: MISS. CDN edges and shared HTTP caches will still cache the personalized HTML — same shape of leak the PR description targets. The fix needs to either swap the response's Cache-Control to no-store post-stream, or hold response headers until the cache decision is finalized.

2. No coverage for dynamic = "error" route handlers. The PR adds wrapRequireStaticNextUrl, the request.* throwing branch, and the accessError plumbing in configureAppRouteStaticGenerationContext, but every new test exercises force-static only. A test analogous to tests/app-route-handler-runtime.test.ts's force-static case — asserting request.headers, request.cookies, request.url, request.ip, request.geo, nextUrl.search, etc. throw with the route-pattern message — would lock in this behavior. Without it, regressions in the error path (e.g. accidentally falling through default) won't be caught.

3. runInRevalidationContext doesn't propagate accessError for dynamic = "error". In the route-handler regen block in app-rsc-entry.ts around 2151, __revalHeadCtx only carries forceStatic: handler.dynamic === "force-static". The route-handler path is mostly fine because runAppRouteHandler re-installs accessError via configureAppRouteStaticGenerationContext, but the page-revalidation block (line ~2308) builds an empty headers context with no accessError story at all. Worth either documenting that pages don't honor dynamic = "error" during regen, or threading an accessError in when isDynamicError is true.

4. Minor: forceStaticCookies is not sealed. Next.js wraps the empty cookies in RequestCookiesAdapter.seal(...) so set / delete throw. wrapRequest returns a bare new RequestCookies(new Headers()), so a force-static route handler that calls request.cookies.set(...) will silently succeed with no effect. Probably harmless given the rest of the response is static, but it's a behavioral divergence from upstream.

return finalizeAppPageHtmlCacheResponse(isrResponse, {
capturedRscDataPromise: isrRscDataPromise,
cleanPathname: options.cleanPathname,
consumeDynamicUsage: options.consumeDynamicUsage,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Threading consumeDynamicUsage into finalizeAppPageHtmlCacheResponse correctly closes the internal-ISR-write side of the leak, but the response itself was already shaped by resolveAppPageHtmlResponsePolicy higher up in this function against the eager pre-stream dynamicUsedDuringRender. So when a lazy server component calls headers() / cookies() during stream consumption:

  • the ISR write is now skipped (good),
  • but the response we already streamed to the client still has Cache-Control: s-maxage=N, stale-while-revalidate and X-Vinext-Cache: MISS, so CDN edges and shared HTTP caches will still cache the personalized HTML.

For full parity with the PR's stated goal, either rewrite the response headers to no-store from inside finalizeAppPageHtmlCacheResponse once the post-stream consumeDynamicUsage() flips, or buffer ISR-eligible HTML and decide headers + write together. The current state is a partial fix — internal cache is safe, response cache is still leaky.

expect(tracked.request.nextUrl.search).toBe("");
expect(tracked.request.nextUrl.searchParams.get("secret")).toBeNull();
expect(tracked.didAccessDynamicRequest()).toBe(false);
expect(accesses).toEqual([]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no symmetric coverage for requestMode: "error". The PR adds wrapRequireStaticNextUrl plus a throwing branch in requestHandler.get for request.headers / cookies / url / ip / geo / body methods, plus a staticGenerationErrorMessage carrying the route pattern — none of which are exercised by tests. A case mirroring this force-static test that asserts each of those access paths throws with Route /path with \dynamic = "error"` couldn't be rendered statically because it used a dynamic request API.` would lock the behavior in and protect against future fall-through-to-default regressions.

It would also be the right place to verify that request.clone() in error mode keeps throwing on access (the clone switch case re-wraps with wrapRequest, but a regression that returned target.clone directly would silently break the contract).

case "text":
case "arrayBuffer":
case "formData":
return throwStaticGenerationError(`request.${String(prop)}`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two minor divergences from Next.js's requireStaticRequestHandlers worth a sanity check:

  1. Next.js does not include ip / geo in the throwing list for require-static — they fall through to the default branch and return whatever the underlying NextRequest.ip / geo resolve to. vinext throws on them. This is fine (and arguably safer) given vinext keeps ip / geo on NextRequest as a Cloudflare-compat surface, but a comment noting it's a deliberate divergence rather than a port mistake would help future readers.
  2. Next.js throws StaticGenBailoutError; this PR throws a plain Error. vinext doesn't currently distinguish that error class anywhere, so it's a no-op for now, but if you ever wire up special handling for static-gen bailout (e.g. preventing the error from being treated as a 500 in the route-handler error reporter), a custom error class would be useful.

forceStaticHeaders ??= new Headers();
return forceStaticHeaders;
case "cookies":
forceStaticCookies ??= new RequestCookies(new Headers());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Next.js wraps the empty cookies in RequestCookiesAdapter.seal(...) so request.cookies.set(...) / .delete(...) throw a ReadonlyRequestCookiesError rather than silently mutating an empty map. vinext returns a bare RequestCookies here, so a force-static route handler that calls request.cookies.set("x", "y") will succeed and leave the rest of the handler thinking it set a cookie. The response is static and no Set-Cookie is emitted, so it's mostly harmless, but it's a small behavioral divergence from upstream.

If you have a _sealCookies helper in shims/headers.ts, exposing it (or an equivalent in shims/server.ts) and reusing it here would line up the behavior.

const __revalHeadCtx = {
headers: new Headers(),
cookies: new Map(),
forceStatic: handler.dynamic === "force-static",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For dynamic = "error" route handlers the regen context only carries forceStatic: false (since the comparison is strictly to "force-static"). The runAppRouteHandler call inside the regen will re-install accessError via configureAppRouteStaticGenerationContext, so foreground execution is correct.

The analogous revalidation block for pages (around line 2308 below) doesn't have an accessError path at all — it builds an empty __revalHeadCtx and runs the page render. For a dynamic = "error" page that ended up with a stale ISR entry (e.g. because the dynamic config changed since the entry was written), the regen would silently succeed with empty headers/cookies instead of erroring. Worth either documenting that pages don't honor dynamic = "error" during regen, or mirroring the route-handler approach and threading an accessError into the page regen headers context when isDynamicError is true.

page,
}) => {
// Intercept the .rsc request for /about and return a 500 error. This
const targetPath = "/rsc-fetch-error-target";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated to the cache-leak fix — would land cleaner as its own PR with a title that reflects the e2e test stabilization. Bundling it here makes it easy to revert one without the other if the dedicated-fixture approach turns out to interact poorly with prefetch in some browser. Not blocking.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 26, 2026

Review submitted successfully at #907 (review).

Summary of feedback posted:

  1. Significant gap: The HTML cache-write skip plugs the internal ISR write, but resolveAppPageHtmlResponsePolicy already shaped the response with Cache-Control: s-maxage=N and X-Vinext-Cache: MISS based on the pre-stream dynamic check. Downstream CDNs/shared caches will still cache personalized HTML — partial fix for the PR's stated security goal.

  2. Missing test coverage for dynamic = "error" route handlers — the PR adds wrapRequireStaticNextUrl and the throwing request.* branch, but only force-static is tested.

  3. Page revalidation context doesn't propagate accessError for dynamic = "error" pages (route handlers are mostly fine because configureAppRouteStaticGenerationContext re-installs it).

  4. Minor: forceStaticCookies returns a bare RequestCookies instead of a sealed one (Next.js seals via RequestCookiesAdapter.seal), so request.cookies.set(...) silently succeeds in force-static mode.

  5. Two minor divergences noted in requireStaticRequestHandlers (ip/geo throwing vs Next.js's fall-through; plain Error vs StaticGenBailoutError).

  6. Unrelated e2e test refactor bundled in this PR — suggested splitting it out.

github run

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/cache-isolation-fixes branch 3 times, most recently from 297988a to 1f97392 Compare April 26, 2026 15:37
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

1 similar comment
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/cache-isolation-fixes branch from 1f97392 to b9dec59 Compare April 27, 2026 03:24
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid follow-up on the prior review. The HTML-path leak that the previous bonk pass flagged is genuinely closed now: finalizeAppPageHtmlCacheResponse overrides Cache-Control on the streamed client response to no-store, must-revalidate before it gets returned, so shared caches no longer see the speculative s-maxage=N, stale-while-revalidate while the internal ISR write is still pending the post-stream consumeDynamicUsage() re-check. The new tests/app-page-cache.test.ts and tests/app-page-render.test.ts assertions pin that exactly. The dynamic = "error" route handler now has parity coverage with force-static (proxy throws on request.headers/cookies/url/ip/geo/body methods plus all nextUrl.* reads, and the clone path stays wrapped), the page-revalidation __revalHeadCtx propagates accessError for dynamic = "error", and the force-static route-handler cookies are now sealed via sealRequestCookies — items 2-4 from the prior review look addressed.

A few items still worth looking at before merge:

  1. RSC response Cache-Control still leaks the post-stream dynamic decision. The HTML path now correctly rewrites Cache-Control on the response before returning, but the RSC path doesn't have the same treatment. buildAppPageRscResponse shapes the response with Cache-Control: s-maxage=N, stale-while-revalidate based on the eager dynamicUsedDuringBuild, the response is returned to the client immediately, and only later does scheduleAppPageRscCacheWrite re-check consumeDynamicUsage() to skip the internal write. If a lazy server component touches cookies() / headers() while the RSC stream is being consumed, the internal ISR write is correctly skipped, but the response we already returned still tells shared caches to cache the personalized RSC payload for s-maxage seconds. RSC payloads are not as widely shared-cached as HTML, but for full parity with the HTML fix the same response-rewrite should apply: either tee the RSC stream and only emit headers after the post-stream check resolves, or downgrade Cache-Control to no-store on the response stream wrapper when consumeDynamicUsage() flips. Worth at least an explicit comment marking this as a known asymmetry rather than letting it look like the HTML fix accidentally drifted.

  2. ReadonlyRequestCookiesError is duplicated. packages/vinext/src/shims/headers.ts already defines ReadonlyRequestCookiesError plus a _sealCookies helper that wraps the local RequestCookies (the Map<string,string>-based one). The new sealRequestCookies in packages/vinext/src/shims/server.ts defines a second ReadonlyRequestCookiesError with the same message and wraps the other RequestCookies class (the Headers-based one). Same name, same message, two classes — a future change to the error message has to be made in both places or behavior diverges. Worth either consolidating into a single shared error type or at least a comment in each call site noting the deliberate duplication and why the two RequestCookies classes can't be unified yet.

  3. Static-generation error message diverges from Next.js wording. The thrown message is Route ${route} with \dynamic = "error"` couldn't be rendered statically because it used a dynamic request API.Next.js's equivalent (inpackages/next/src/server/request/utils.tsanderrors.jsonentry 543) interpolates the actual expression and appends a docs link:Route ${route} with `dynamic = "error"` couldn't be rendered statically because it used ${expression}. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering`. The vinext proxy already passes the expression name to throwStaticGenerationError("request.headers") etc., so the underlying call site has the data — just the formatter discards it. Threading it into the message (and adding the docs URL) would line up with Next.js error reporting conventions and keep error-comparison tests portable.

  4. Unrelated e2e refactor still bundled. The tests/e2e/app-router/rsc-fetch-errors.spec.ts rework (dedicated unlinked fixture pages, targetRscHits === 1 exact assertion, real-server redirect chain instead of a route-fulfill chain) is a stabilization of pre-existing tests and isn't covered by the PR title. It's reasonable on its own merits, but co-locating it with the cache-leak fix makes a partial revert (e.g. if the dedicated-fixture approach interacts badly with prefetch in some browser) drag the cache-leak fix with it. Already noted in the previous bonk review; not blocking.

chunks.push(decoder.decode());

if (options.consumeDynamicUsage()) {
options.isrDebug?.("HTML cache write skipped (dynamic usage during render)", htmlKey);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RSC response Cache-Control is not rewritten the way finalizeAppPageHtmlCacheResponse rewrites the HTML response above. buildAppPageRscResponse set Cache-Control: s-maxage=${revalidateSeconds}, stale-while-revalidate based on the eager pre-stream dynamicUsedDuringBuild, returned the response to the client, and only later does scheduleAppPageRscCacheWrite re-check consumeDynamicUsage() to decide whether to skip the internal ISR write. When a lazy server component touches cookies() / headers() during RSC stream consumption:

  • the ISR write is correctly skipped (good),
  • but the RSC response that already streamed to the client still carries the cacheable Cache-Control, so a shared CDN / HTTP cache will still cache the personalized RSC payload.

For parity with the HTML fix landed a few lines above (finalizeAppPageHtmlCacheResponse overrides Cache-Control to no-store, must-revalidate on the response wrapper), the same response-header rewrite should apply on the RSC side — either by teeing the RSC body and emitting no-store on the wrapper stream when the post-stream check flips, or by buffering the RSC response and finalizing headers + write together. As-is, the RSC path is a partial fix relative to the PR's stated goal.

super(
"Cookies can only be modified in a Server Action or Route Handler. Read more: https://nextjs.org/docs/app/api-reference/functions/cookies#options",
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's already a ReadonlyRequestCookiesError in packages/vinext/src/shims/headers.ts with the same message, plus a _sealCookies helper that wraps the RequestCookies defined in that file. This second ReadonlyRequestCookiesError here in shims/server.ts is identical in message but a separate class, and the sealRequestCookies helper below wraps the other RequestCookies class (the Headers-based one used by NextRequest). A future change to the error message would silently drift the two paths. Either consolidate into a single shared error type re-exported from one shim, or add a brief comment in both files noting the duplication is deliberate because the two RequestCookies classes haven't been unified yet.

`Route ${route} with \`dynamic = "error"\` couldn't be rendered statically ` +
"because it used a dynamic request API."
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Next.js's analogous error formatter (see packages/next/src/server/request/utils.ts and errors.json entry 543) interpolates the specific ${expression} (e.g. request.headers, nextUrl.search) and appends See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering. The proxy in app-route-handler-runtime.ts already passes the expression name to throwStaticGenerationError("request.headers") / throwStaticGenerationError("nextUrl.search") etc., so the data is available — just discarded by the current formatter, which uses a single generic "a dynamic request API" string for every access. Threading the expression in (and matching Next.js's wording + docs link) would line up with errors.json entry 543 and make the error comparable to upstream for users / tooling that pattern-match on it.

Suggested change
}
function getStaticGenerationErrorMessage(routePattern?: string, expression?: string): string {
const route = routePattern ?? "unknown route";
const expr = expression ?? "a dynamic request API";
return (
`Route ${route} with \`dynamic = "error"\` couldn't be rendered statically ` +
`because it used ${expr}. ` +
"See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering"
);
}

// home page's visible Links, so a count of 0 means the test skipped the
// !ok guard path, while a count above 1 means hydration fell back to a
// post-reload .rsc fetch or entered a reload loop.
expect(targetRscHits).toBe(1);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This refactor (dedicated unlinked fixture target + tightened toBe(1) assertion + real-server redirect chain instead of a route.fulfill 307→500) is unrelated to the cache-leak fix in the PR title. Not blocking, but it would land cleaner as its own PR — if the dedicated-fixture approach turns out to interact poorly with browser prefetch heuristics later and needs reverting, bundling it here drags the cache-leak fix back too.

(Already raised in the previous bonk review — restating for visibility now that the PR has been re-pushed.)

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 27, 2026

All four inline comments and the top-level review have been posted successfully.

Summary

Posted a review on PR #907 with one summary comment and four inline comments:

Top-level review acknowledges that the prior bonk feedback has been mostly addressed (HTML response Cache-Control rewrite, dynamic = "error" route handler test coverage, accessError propagation in page revalidation, sealed force-static cookies), and flags four remaining concerns.

Inline comments:

  1. app-page-cache.ts:260 — RSC response Cache-Control still leaks personalized data via shared CDN caches. The HTML path was fixed (finalizeAppPageHtmlCacheResponse now overrides Cache-Control to no-store, must-revalidate), but the RSC path (scheduleAppPageRscCacheWrite) only skips the internal write — the response was already sent with the cacheable header.

  2. shims/server.ts:568 — Duplicate ReadonlyRequestCookiesError class (one already exists in shims/headers.ts). Same message, two classes that wrap two different RequestCookies implementations. Drift risk on any future message change.

  3. app-route-handler-execution.ts:92 — Static-generation error message diverges from Next.js's errors.json entry 543 wording: doesn't interpolate the access expression and omits the docs link, even though the proxy already passes the expression name. Included a suggestion block.

  4. tests/e2e/app-router/rsc-fetch-errors.spec.ts:146 — Unrelated e2e test refactor still bundled with the cache-leak fix. Not blocking, but would land cleaner as its own PR.

github run

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/cache-isolation-fixes branch from b9dec59 to c6aec62 Compare April 27, 2026 06:49
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/cache-isolation-fixes branch from c6aec62 to 1419580 Compare April 27, 2026 06:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants