Information disclosure

HIGH
vercel/next.js
Commit: 085311e3b629
Affected: Next.js 16.2.x prior to this patch (i.e., 16.2.2 and earlier)
2026-05-19 17:04 UTC

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)` + )} + </> + ) +}
← Back to Alerts View on GitHub →