Information disclosure
Description
The commit fixes a cross-parameter information disclosure in the segment cache that occurs when using prefetch={true} with cachedNavigations and pages with param fallback. Previously, a Full prefetch could cause the cache to be re-keyed using a potentially incomplete set of varyParams, potentially combining param-specific content into a single cache entry keyed by a vary path with Fallbacks. A subsequent lookup for a different param value could read content from this mixed cache entry, leaking information across param values. The fix pins the cache entry to its concrete vary path for Full fetches (i.e., skips re-keying when fetchStrategy === FetchStrategy.Full), aligning Full prefetch behavior with the non-Full (cachedNavigations-disabled) path where the server does not provide varyParams. This mitigates cross-param information disclosure. The change also adjusts test coverage to validate non-leaking behavior.
Proof of Concept
PoC outline (using a minimal Playwright-based repro against a Next.js app configured with app-dir, cachedNavigations, and a page that uses param fallbacks):
Prerequisites:
- A Next.js app (16.x) with an app-dir setup, and a page that renders content dependent on a param (e.g., /with-fallback-params/[slug]) with fallback params. The hub page should expose links for /with-fallback-params/foo and /with-fallback-params/bar and support prefetch={true} via a client-side link method.
- cachedNavigations enabled and support for Full prefetch semantics.
- A test page structure similar to the one in the commit (with a hub displaying Param: foo and Param: bar sections).
Reproduction steps (pre-fix behavior vs workaround you’ll observe after patch):
1) Open the hub page and trigger a Full prefetch for /with-fallback-params/foo by clicking a prefetch-enabled link (prefetch={true}).
2) Navigate to /with-fallback-params/foo and observe the rendered content (should show Param: foo).
3) Return to the hub and trigger a Full prefetch for /with-fallback-params/bar.
4) Navigate to /with-fallback-params/bar and observe the content. In the vulnerable version, the content for bar could incorrectly reflect foo due to cross-param leakage in the cached entry.
Expected (post-fix) behavior:
- The bar page should render Param: bar, not leaking Param: foo, because the cache entry remains keyed to the concrete vary path for Full prefetches and does not re-key using varyParams.
Example PoC script (Playwright-like pseudocode):
import { chromium } from 'playwright';
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto('http://localhost:3000/with-fallback-params');
// 1) Trigger prefetch for foo via a prefetch-enabled control
await page.click('input[data-link-accordion="/with-fallback-params/foo"]');
// 2) Navigate to foo and capture content
await page.click('a[href="/with-fallback-params/foo"]');
const fooContent = await page.textContent('#params-boundary'); // e.g., 'Param: foo'
// 3) Return to hub and trigger prefetch for bar
await page.goto('http://localhost:3000/with-fallback-params');
await page.click('input[data-link-accordion="/with-fallback-params/bar"]');
// 4) Navigate to bar and capture content
await page.click('a[href="/with-fallback-params/bar"]');
const barContent = await page.textContent('#params-boundary'); // should be 'Param: bar'
console.log({ fooContent, barContent });
await browser.close();
})();
Notes:
- The PoC assumes the app exposes a DOM element with id 'params-boundary' displaying the current param state and uses a data attribute data-link-accordion on a checkbox to trigger prefetch as in the test fixture.
- In a vulnerable environment, barContent may equal fooContent due to cross-param leakage; after the fix, barContent should be 'Param: bar'.
Commit Details
Author: Hendrik Liebau
Date: 2026-05-19 16:21 UTC
Message:
Fix cross-param leak with `prefetch={true}` and `cachedNavigations` (#93940)
When both `cachedNavigations` and `varyParams` are enabled and a `<Link
prefetch={true}>` points to a page with fallback params, the resulting
Full prefetch could leak param-specific content into the cache entry of
a different param value. As of today, `varyParams` tracking only works
within the static stage portion of a response. A Full prefetch response
covers all stages, so the server-reported set is incomplete and can't be
trusted — it may arrive empty. The client then re-keys the entry under a
vary path where every path param is replaced with `<Fallback>`, so a
subsequent lookup for a different param value collides on that entry and
reads the previously prefetched page's content.
This change makes `fulfillEntrySpawnedByRuntimePrefetch` skip the
re-keying when `fetchStrategy === FetchStrategy.Full` and keep the entry
pinned to its concrete vary path. That aligns Full prefetches with how
they're already keyed when `cachedNavigations` is disabled, where the
server sends no `varyParams` and the client never re-keys. The trade-off
is that we miss out on cross-param sharing for Full prefetches even when
a segment genuinely doesn't depend on a given param. Ideally we'd track
`varyParams` through the dynamic stage too, but that's not possible
today: the `varyParams` thenable would need to resolve before the render
completes, while React waits for the thenable to resolve before
completing the render — a deadlock in the Flight response.
This limitation should naturally go away as `prefetch={true}` evolves
under Cache Components, where it will likely become a runtime prefetch —
a render aborted at a known stage — at which point we can track
`varyParams` properly across stages for these requests, likely switching
from a thenable to an async iterable.
Triage Assessment
Vulnerability Type: Information disclosure
Confidence: HIGH
Reasoning:
The commit addresses a cross-param data leakage where a Full prefetch could cause param-specific content to be cached under a different vary path, enabling a subsequent lookup to read unrelated content. It changes the cache keying behavior to avoid re-keying by varyParams for Full prefetches, aligning with security expectations and preventing cross-param information disclosure.
Verification Assessment
Vulnerability Type: Information disclosure
Confidence: HIGH
Affected Versions: Next.js 16.2.x prior to this patch (i.e., 16.2.2 and earlier)
Code Diff
diff --git a/packages/next/src/client/components/segment-cache/cache.ts b/packages/next/src/client/components/segment-cache/cache.ts
index d0ecf46e1cdb..3430c3ac7ffc 100644
--- a/packages/next/src/client/components/segment-cache/cache.ts
+++ b/packages/next/src/client/components/segment-cache/cache.ts
@@ -2594,6 +2594,24 @@ function fulfillEntrySpawnedByRuntimePrefetch(
PendingSegmentCacheEntry
> | null
) {
+ // Decide whether to re-key the entry under a more generic vary path based on
+ // which params the segment actually depends on.
+ //
+ // Skip re-keying for Full prefetches: as of today, `varyParams` tracking only
+ // works within the static stage portion of a response. A Full prefetch
+ // response covers all stages, and we can't track params during the dynamic
+ // stage without dead-locking the Flight stream, so the server-reported set is
+ // incomplete and can't be trusted for the full response. Re-keying with an
+ // untrustworthy set could replace concrete params with Fallback and let
+ // unrelated URLs read each other's content from the cache.
+ //
+ // When non-null, this is the param set to re-key by; when null, the entry
+ // stays keyed by the request's concrete vary path.
+ const fulfilledVaryParams =
+ process.env.__NEXT_VARY_PARAMS && fetchStrategy !== FetchStrategy.Full
+ ? segmentVaryParams
+ : null
+
// We should only write into cache entries that are owned by us. Or create
// a new one and write into that. We must never write over an entry that was
// created by a different task, because that causes data races.
@@ -2608,11 +2626,10 @@ function fulfillEntrySpawnedByRuntimePrefetch(
staleAt,
isPartial
)
- // Re-key the entry based on which params the segment actually depends on.
- if (process.env.__NEXT_VARY_PARAMS && segmentVaryParams !== null) {
+ if (fulfilledVaryParams !== null) {
const fulfilledVaryPath = getFulfilledSegmentVaryPath(
tree.varyPath,
- segmentVaryParams
+ fulfilledVaryParams
)
const isRevalidation = false
setInCacheMap(
@@ -2638,11 +2655,10 @@ function fulfillEntrySpawnedByRuntimePrefetch(
staleAt,
isPartial
)
- // Re-key the entry based on which params the segment actually depends on.
- if (process.env.__NEXT_VARY_PARAMS && segmentVaryParams !== null) {
+ if (fulfilledVaryParams !== null) {
const fulfilledVaryPath = getFulfilledSegmentVaryPath(
tree.varyPath,
- segmentVaryParams
+ fulfilledVaryParams
)
const isRevalidation = false
setInCacheMap(
@@ -2664,11 +2680,9 @@ function fulfillEntrySpawnedByRuntimePrefetch(
staleAt,
isPartial
)
- // Use the fulfilled vary path if available, otherwise fall back to
- // the request vary path.
const varyPath =
- process.env.__NEXT_VARY_PARAMS && segmentVaryParams !== null
- ? getFulfilledSegmentVaryPath(tree.varyPath, segmentVaryParams)
+ fulfilledVaryParams !== null
+ ? getFulfilledSegmentVaryPath(tree.varyPath, fulfilledVaryParams)
: getSegmentVaryPathForRequest(fetchStrategy, tree)
upsertSegmentEntry(now, varyPath, newEntry)
}
diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/[slug]/layout.tsx b/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/[slug]/layout.tsx
deleted file mode 100644
index 836805e6038e..000000000000
--- a/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/[slug]/layout.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import Link from 'next/link'
-import { ReactNode } from 'react'
-
-export default function ParamsLayout({ children }: { children: ReactNode }) {
- return (
- <>
- <ul>
- <li>
- <Link href="/with-fallback-params/foo" prefetch={false}>
- /with-fallback-params/foo
- </Link>
- </li>
- <li>
- <Link href="/with-fallback-params/bar" prefetch={false}>
- /with-fallback-params/bar
- </Link>
- </li>
- </ul>
- {children}
- </>
- )
-}
diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/layout.tsx b/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/layout.tsx
new file mode 100644
index 000000000000..fcc69c74f16d
--- /dev/null
+++ b/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/layout.tsx
@@ -0,0 +1,51 @@
+'use client'
+
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { ReactNode } from 'react'
+import { LinkAccordion } from '../../components/link-accordion'
+
+export default function ParamsLayout({ children }: { children: ReactNode }) {
+ const { bfcacheId } = useRouter()
+
+ return (
+ <>
+ <ul>
+ <li>
+ <Link href="/with-fallback-params" prefetch={false}>
+ /with-fallback-params
+ </Link>
+ </li>
+ <li>
+ <Link href="/with-fallback-params/foo" prefetch={false}>
+ /with-fallback-params/foo
+ </Link>
+ </li>
+ <li>
+ <Link href="/with-fallback-params/bar" prefetch={false}>
+ /with-fallback-params/bar
+ </Link>
+ </li>
+ <li>
+ <LinkAccordion
+ key={bfcacheId}
+ href="/with-fallback-params/foo"
+ prefetch={true}
+ >
+ /with-fallback-params/foo (prefetch=true)
+ </LinkAccordion>
+ </li>
+ <li>
+ <LinkAccordion
+ key={bfcacheId}
+ href="/with-fallback-params/bar"
+ prefetch={true}
+ >
+ /with-fallback-params/bar (prefetch=true)
+ </LinkAccordion>
+ </li>
+ </ul>
+ {children}
+ </>
+ )
+}
diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/page.tsx b/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/page.tsx
new file mode 100644
index 000000000000..2dcc5a7d10d4
--- /dev/null
+++ b/test/e2e/app-dir/segment-cache/cached-navigations/app/with-fallback-params/page.tsx
@@ -0,0 +1,3 @@
+export default function FallbackParamsHub() {
+ return <h1>Fallback Params Hub</h1>
+}
diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations.test.ts b/test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations.test.ts
index 95c7e234bceb..fdfb8197e8e1 100644
--- a/test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations.test.ts
+++ b/test/e2e/app-dir/segment-cache/cached-navigations/cached-navigations.test.ts
@@ -935,4 +935,58 @@ describe('cached navigations', () => {
'Dynamic content'
)
})
+
+ it('does not leak resolved param-specific content across params when using prefetch={true}', async () => {
+ let page: Playwright.Page
+ const browser = await next.browser('/with-fallback-params', {
+ beforePageLoad(p: Playwright.Page) {
+ page = p
+ },
+ })
+ const act = createRouterAct(page)
+
+ // 1. Unveil the foo link. With prefetch={true} this triggers a Full
+ // dynamic prefetch that returns the fully rendered page.
+ await act(async () => {
+ await browser
+ .elementByCss('input[data-link-accordion="/with-fallback-params/foo"]')
+ .click()
+ })
+
+ // 2. Navigate to /with-fallback-params/foo using the prefetched data.
+ await act(async () => {
+ await browser.elementByCss('a[href="/with-fallback-params/foo"]').click()
+ }, 'no-requests')
+ await retry(async () => {
+ expect(await browser.elementById('params-boundary').text()).toBe(
+ 'Param: foo'
+ )
+ })
+
+ // 3. Return to the hub via the layout's back link.
+ await browser.elementByCss('a[href="/with-fallback-params"]')?.click()
+ await retry(async () => {
+ expect(await browser.elementByCss('h1').text()).toBe(
+ 'Fallback Params Hub'
+ )
+ })
+
+ // 4. Unveil the bar link.
+ await act(async () => {
+ await browser
+ .elementByCss('input[data-link-accordion="/with-fallback-params/bar"]')
+ .click()
+ })
+
+ // 5. Navigate to /with-fallback-params/bar; should render bar's content
+ // (sourced from the bar prefetch in step 4), not foo's.
+ await act(async () => {
+ await browser.elementByCss('a[href="/with-fallback-params/bar"]').click()
+ }, 'no-requests')
+ await retry(async () => {
+ const barContent = await browser.elementById('params-boundary').text()
+ expect(barContent).toBe('Param: bar')
+ expect(barContent).not.toContain('foo')
+ })
+ })
})
diff --git a/test/e2e/app-dir/segment-cache/cached-navigations/components/link-accordion.tsx b/test/e2e/app-dir/segment-cache/cached-navigations/components/link-accordion.tsx
new file mode 100644
index 000000000000..fd8f6781732e
--- /dev/null
+++ b/test/e2e/app-dir/segment-cache/cached-navigations/components/link-accordion.tsx
@@ -0,0 +1,33 @@
+'use client'
+
+import Link, { LinkProps } from 'next/link'
+import { useState } from 'react'
+
+export function LinkAccordion({
+ href,
+ children,
+ prefetch,
+}: {
+ href: string
+ children: React.ReactNode
+ prefetch?: LinkProps['prefetch']
+}) {
+ const [isVisible, setIsVisible] = useState(false)
+ return (
+ <>
+ <input
+ type="checkbox"
+ checked={isVisible}
+ onChange={() => setIsVisible(!isVisible)}
+ data-link-accordion={href}
+ />
+ {isVisible ? (
+ <Link href={href} prefetch={prefetch}>
+ {children}
+ </Link>
+ ) : (
+ `${children} (link is hidden)`
+ )}
+ </>
+ )
+}