Skip to content

fix(cache): security — do not edge-cache App Router responses before dynamic usage is proven - #2731

Open
NathanDrake2406 wants to merge 4 commits into
cloudflare:mainfrom
NathanDrake2406:fix/cdn-shared-cache-unproven-dynamic
Open

fix(cache): security — do not edge-cache App Router responses before dynamic usage is proven#2731
NathanDrake2406 wants to merge 4 commits into
cloudflare:mainfrom
NathanDrake2406:fix/cdn-shared-cache-unproven-dynamic

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Summary

Security fix. Under an edge-managed CDN cache adapter, an App Router response could be advertised to a shared CDN cache as cacheable before the render had proven it was non-dynamic. If the render turned out to be dynamic, that advertisement could not be withdrawn, so a personalized page could be stored by the edge and served to other users of the same URL.

Severity: high — cross-user disclosure of authenticated page content, reachable over ordinary public HTTP traffic on a default vinext init --platform=cloudflare deployment.

Mechanism

An App Router page can only be proven non-dynamic once its stream drains — a Suspended server component may read cookies() / headers() long after the shell has flushed. finalizeAppPageHtmlCacheResponse / finalizeAppPageRscCacheResponse account for this by stamping headers with pendingDynamicCheck: true and, when the render turns out dynamic, skipping the origin cache write.

Skipping the write is a sufficient correction only for an origin-managed store. CloudflareCdnCacheAdapter keeps no origin store (get() returns null, set() is a no-op), so its response headers alone decide whether the Workers Cache stores the page. Once CDN-Cache-Control: public, max-age=… has left the origin there is nothing left to skip, and responses do not Vary on Cookie, so the edge key is effectively the URL.

Note this was a considered design decision, not an oversight — the pendingDynamicCheck doc comment explicitly contemplated edge adapters emitting edge-only cache headers in this state. The gap is that the design distinguished caches by location (browser vs. edge) and not by whether the store is shared between users.

Change

Adds requiresProvenCachePolicy to the CdnCacheAdapter contract, set by CloudflareCdnCacheAdapter.

When the active adapter declares it, the App Router finalizers hold the response until the stream has resolved the render's dynamic-ness, then emit a final policy:

  • proven static → the real cacheable policy (edge caching still works as before)
  • dynamic → no-store
  • cache-write error → fails closed, treated as dynamic

Adapters that manage their own origin store are untouched and keep streaming with the existing pendingDynamicCheck behaviour, so this is opt-in per adapter and default (non-Cloudflare) behaviour is unchanged.

Cost

Under the Workers Cache adapter, a cacheable App Router response is now buffered at the origin instead of streamed. This applies only to renders that reach the origin — i.e. cache misses — since the edge absorbs hits and stale-while-revalidate traffic. TTFB on a miss regresses; hit-path behaviour is unchanged.

An alternative that preserves streaming would be to gate on the existing render-observation / cache-proof machinery, emitting edge-cacheable headers only for a route with a stored negative request-API proof and streaming the first render as no-store. That is a larger change; happy to go that route instead if preferred.

Validation

  • New behaviour tests in tests/cloudflare-cdn-cache.test.ts assert the response contract for both outcomes: a late-dynamic render emits no CDN-Cache-Control and no-store; a proven-static render still emits the full edge SWR policy. Verified that the first test fails on main and the second passes both before and after — so the fix is not simply disabling edge caching.
  • Removed a test that asserted the pending-dynamic response should carry edge-cacheable headers; that expectation encoded the bug and is superseded by the tests above.
  • tsc --noEmit clean; existing call sites in tests/app-page-cache.test.ts updated to await the finalizers, which may now return a promise.
  • Full repo check and staged unit/integration suites pass (280 tests across the cache/render suites).

Review path

  1. packages/vinext/src/shims/cdn-cache.ts — the new contract and why it exists
  2. packages/cloudflare/src/cache/cdn-adapter.runtime.ts — one flag
  3. packages/vinext/src/server/app-page-cache-finalizer.ts — the branch; the streaming path is unchanged, the proven path is new

Risks

  • finalizeAppPage{Html,Rsc}CacheResponse now return Response | Promise<Response>. Both production call sites are inside renderAppPageLifecycle, which already returns Promise<Response>, so no signature changes rippled outward — but this is worth a look for any out-of-tree callers.
  • Buffering changes memory behaviour for very large cacheable pages under the Workers Cache adapter. The HTML path already buffered the same bytes for the cache write, so this is not a new allocation class, but it does hold them slightly longer.

NathanDrake2406 and others added 2 commits July 28, 2026 00:56
…ing App Router responses

An App Router page can only be proven non-dynamic after its stream drains:
a Suspended server component may read cookies()/headers() long after the
shell has flushed. The finalizers therefore stamp response cache headers
with `pendingDynamicCheck: true` and, if the render turns out dynamic,
skip the origin cache write.

That correction only works for an origin-managed store. The Cloudflare CDN
adapter keeps no origin store, so its headers alone decide whether the
*shared* Workers Cache stores the page — and a header already sent cannot
be retracted. A late-dynamic render could therefore leave the origin
advertising `CDN-Cache-Control: public, max-age=...`, letting the edge
store one user's personalized HTML or RSC payload and replay it to the
next requester of the same URL (responses do not Vary on Cookie).

Add `requiresProvenCachePolicy` to the CDN adapter contract. When the
active adapter sets it, the App Router finalizers hold the response until
the stream has proven the render's dynamic-ness, then emit a final policy:
the real cacheable policy when proven static, `no-store` when dynamic.
Cache-write errors fail closed. Origin-managed adapters keep the existing
streaming behaviour unchanged.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@pkg-pr-new

pkg-pr-new Bot commented Jul 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2731
npm i https://pkg.pr.new/create-vinext-app@2731
npm i https://pkg.pr.new/@vinext/types@2731
npm i https://pkg.pr.new/vinext@2731

commit: ac76090

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared ac76090 against base c9a4a84 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 132.4 KB 132.4 KB ⚫ -0.0%
Client entry size (gzip) vinext 119.8 KB 119.8 KB ⚫ -0.0%
Dev server cold start vinext 2.99 s 2.99 s ⚫ -0.1%
Production build time vinext 3.33 s 3.29 s ⚫ -1.1%
RSC entry closure size (gzip) vinext 105.6 KB 105.9 KB ⚫ +0.3%
Server bundle size (gzip) vinext 179.8 KB 180.1 KB ⚫ +0.2%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6a487d0328

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

readStreamAsText(streamForClient),
]);
const headers = new Headers(response.headers);
applyProvenCdnHeaders(headers, renderedCacheControl, dynamicUsed, options.getPageTags(), {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Emit the resolved render cache policy

When a streamed render tightens its policy through a late cacheLife() or cacheable fetch—for example, a route-level revalidate of 60 seconds resolving to 10—resolveAppPageCacheWritePolicy() computes the effective lifetime, but the outcome retains only dynamicUsed. This call therefore sends the pre-stream renderedCacheControl to the CDN, advertising 60 seconds rather than 10; if the resolved policy is invalid or non-cacheable, it can still advertise the original cacheable policy. Return the resolved policy with the write outcome and build the final HTML/RSC headers from that policy.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 92d9d45.

Verified the two cases: a cacheLife() resolving revalidate: 6010 was advertising 60s to the edge, and one resolving to revalidate: 0 still advertised the original cacheable policy while the origin correctly skipped its write — so the edge would cache a page the framework had decided not to cache.

The write outcome now carries the resolved policy and the final headers derive from it, with a null policy meaning no-store. One thing worth noting for anyone reading the fix: revalidate = false reaches the finalizer as Infinity (via resolveAppPageCacheWriteRevalidateSeconds), which is a valid policy rather than an absent one, so that case maps to STATIC_CACHE_CONTROL instead of falling into the no-store branch — otherwise this would have silently broken force-static edge caching.

Two behaviour tests added, both confirmed to fail against the previous implementation.

Comment on lines +262 to +265
const [{ dynamicUsed }, html] = await Promise.all([
cachePromise,
readStreamAsText(streamForClient),
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reuse the already-buffered response body

For a large App Router response under an adapter requiring a proven policy, this drains both tee branches into independent full strings: cachePromise already holds cachedHtml, while readStreamAsText(streamForClient) decodes and joins a second copy before any response is returned. The duplicated body and decoder chunk arrays can exhaust a Worker's memory on sufficiently large HTML responses; include the buffered HTML in the cache outcome and reuse it for the returned response instead of buffering the client branch again.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, fixed in 92d9d45.

Rather than threading the buffered HTML through the outcome, the proven path now skips the tee altogether — cdnRequiresProvenCachePolicy() is known up front, so it branches before response.body.tee() and reads the body once, reusing that single string for both the cache write and the client response. That drops the second copy and the second decoder chunk array entirely instead of just deduplicating the result.

The streaming path is untouched and still tees. Extracted the HTML cache write into a shared helper so the two paths do not duplicate the write logic.

… the body

Addresses review feedback on the proven-cache-policy path.

The final headers were built from the `Cache-Control` computed before the
stream drained. A `cacheLife()` or cacheable fetch resolving late can
shorten that lifetime, or drop it entirely — so a route declaring 60s that
resolved to 10s advertised 60s to the edge, and one that resolved to
`revalidate: 0` still advertised the original cacheable policy even though
the origin correctly skipped its write. The write outcome now carries the
resolved policy and the final headers are derived from it, with no policy
meaning no-store.

The proven path also drained both tee branches into separate full strings,
holding two copies of a large response plus both decoder chunk arrays. It
now skips the tee entirely and reuses the single buffered body for both the
cache write and the client response.

Extracts the HTML cache write so the streaming and proven paths share it
rather than duplicating the write logic.
…namic' into fix/cdn-shared-cache-unproven-dynamic
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.

1 participant