Information Disclosure
Description
The commit fixes a vulnerability where HTTP access fallbacks during prerender recovery could leak inconsistent internal state or reuse Flight data and chunk references from a previously aborted prerender prelude when not-found/forbidden/unauthorized errors occurred. The fix finds the deepest matching HTTP fallback boundary and renders a segment-scoped fallback via the normal app router while teeing the replacement Flight stream to avoid mixing streams. If no matching boundary exists, the generic error payload is used. This reduces the risk of information disclosure about internal prerender state and flight data during prerender recovery.
Proof of Concept
PoC steps (environment: Next.js 16.x with app dir, prior to the fix):
1) Create a Next.js app with app dir and a page that triggers a notFound() during prerender (e.g., a page at /cases/not-found-suspense using the pattern added in the commit: a page.tsx that calls notFound() and a layout.tsx with Suspense wrapping an async child).
2) Start the dev server (npm run dev) and request http://localhost:3000/cases/not-found-suspense.
3) Observe the HTML/response payload captured by the browser or a tool like curl. In vulnerable versions, the prerender error path could leak internal Flight data or stale references to chunks from the aborted prelude, resulting in a response that contains more than just the Not Found UI (e.g., inline Flight JSON data or internal state artifacts). The test case in this commit uses a not-found boundary with a Suspense-based layout to reproduce this path.
4) In a patched version including this fix, the response should render only the proper Not Found UI with the segment-scoped boundary fallback and should not include leaked internal Flight state or references to un-emitted chunks.
Optional PoC snippet (node fetch to observe leakage in pre-fix environment):
- Prereq: Run Next.js 16.x before the fix (e.g., 16.2.1).
- Script (Node):
const fetch = require('node-fetch');
(async () => {
const res = await fetch('http://localhost:3000/cases/not-found-suspense');
const html = await res.text();
console.log(html.includes('Flight') || html.includes('__flight') ? 'Potential Flight data present' : 'No Flight data detected');
})();
This PoC relies on the exact prerender streaming format used by Next.js and may require an environment that reproduces the pre-fix leakage. The fix aims to make this PoC fail (i.e., no leakage) by ensuring proper boundary-specific fallback handling.
Commit Details
Author: Zack Tanner
Date: 2026-04-02 14:54 UTC
Message:
fix: preserve HTTP access fallbacks during prerender recovery (#92231)
When a `notFound()`, `forbidden()`, or `unauthorized()` error escapes
into the outer prerender recovery path, we were falling back to the
generic error shell flow.
In the `cacheComponents` case, that could leave us with:
- error HTML rendered from `ErrorApp`
- Flight data reused from the aborted prerender prelude
- references to Flight chunks that were never emitted
That is what caused the client-side `Connection closed` failure in
#86251.
Instead of rerendering the full Flight tree or always using the generic
error RSC payload, this change:
- finds the deepest matching HTTP fallback boundary
- rerenders the normal app router payload with that segment-scoped
fallback
- tees the replacement Flight stream so Fizz can render from one copy
while prerender buffering consumes the other
- only takes this path for recoverable HTTP access fallbacks that have a
real boundary (ie a defined not-found/unauthorized/etc)
If no matching boundary exists, we keep the existing generic error
handling.
Fixes #86251
Fixes #90837
Closes #87041
Closes #86251
Closes NAR-711
Closes NEXT-4876
Triage Assessment
Vulnerability Type: Information Disclosure
Confidence: MEDIUM
Reasoning:
The change adjusts how HTTP error fallbacks are handled during prerender recovery to avoid leaking inconsistent or partial error state (e.g., reusing Flight data or incorrect error shells). It targets information disclosure / incorrect error handling in prerendered content, reducing risk of exposing internal state and ensuring proper boundary-specific fallbacks for not-found/unauthorized/forbidden errors.
Verification Assessment
Vulnerability Type: Information Disclosure
Confidence: MEDIUM
Affected Versions: <= 16.2.2
Code Diff
diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx
index ac46b83128ecb..6013e3539e451 100644
--- a/packages/next/src/server/app-render/app-render.tsx
+++ b/packages/next/src/server/app-render/app-render.tsx
@@ -113,7 +113,11 @@ import {
walkTreeWithFlightRouterState,
createFullTreeFlightDataForNavigation,
} from './walk-tree-with-flight-router-state'
-import { createComponentTree, getRootParams } from './create-component-tree'
+import {
+ createComponentTree,
+ getRootParams,
+ type PrerenderHTTPErrorState,
+} from './create-component-tree'
import { getAssetQueryString } from './get-asset-query-string'
import {
getClientReferenceManifest,
@@ -506,6 +510,54 @@ function createNotFoundLoaderTree(loaderTree: LoaderTree): LoaderTree {
]
}
+type HTTPAccessErrorStatusCode = 404 | 403 | 401
+
+function hasPrerenderHTTPErrorBoundary(
+ loaderTree: LoaderTree,
+ triggeredStatus: HTTPAccessErrorStatusCode,
+ authInterrupts: boolean
+): boolean {
+ switch (triggeredStatus) {
+ case 404:
+ return !!loaderTree[2]['not-found']
+ case 403:
+ return authInterrupts && !!loaderTree[2].forbidden
+ case 401:
+ return authInterrupts && !!loaderTree[2].unauthorized
+ default:
+ return false
+ }
+}
+
+function findPrerenderHTTPErrorBoundaryTree(
+ loaderTree: LoaderTree,
+ triggeredStatus: HTTPAccessErrorStatusCode,
+ authInterrupts: boolean
+): LoaderTree | null {
+ let boundaryTree: LoaderTree | null = hasPrerenderHTTPErrorBoundary(
+ loaderTree,
+ triggeredStatus,
+ authInterrupts
+ )
+ ? loaderTree
+ : null
+
+ const childrenTree = loaderTree[1].children
+ if (childrenTree) {
+ const deeperBoundaryTree = findPrerenderHTTPErrorBoundaryTree(
+ childrenTree,
+ triggeredStatus,
+ authInterrupts
+ )
+
+ if (deeperBoundaryTree) {
+ boundaryTree = deeperBoundaryTree
+ }
+ }
+
+ return boundaryTree
+}
+
/**
* Returns a function that parses the dynamic segment and return the associated value.
*/
@@ -1709,6 +1761,9 @@ async function getRSCPayload(
ctx: AppRenderContext,
options: {
is404: boolean
+ // When set, rerender a segment-scoped HTTP fallback inside the normal app
+ // router tree instead of falling back to the generic error shell payload.
+ prerenderHTTPError?: PrerenderHTTPErrorState
staleTimeIterable?: AsyncIterable<number>
staticStageByteLengthPromise?: Promise<number>
runtimePrefetchStream?: ReadableStream<Uint8Array>
@@ -1716,6 +1771,7 @@ async function getRSCPayload(
): Promise<InitialRSCPayload & { P: ReactNode }> {
const {
is404,
+ prerenderHTTPError,
staleTimeIterable,
staticStageByteLengthPromise,
runtimePrefetchStream,
@@ -1789,6 +1845,7 @@ async function getRSCPayload(
preloadCallbacks,
authInterrupts: ctx.renderOpts.experimental.authInterrupts,
MetadataOutlet,
+ prerenderHTTPError,
})
// When the `vary` response header is present with `Next-URL`, that means there's a chance
@@ -7117,16 +7174,46 @@ async function prerenderToStream(
: INFINITE_CACHE,
tags: [...(prerenderStore?.tags || implicitTags.tags)],
})
- const errorRSCPayload = await workUnitAsyncStorage.run(
- prerenderLegacyStore,
- getErrorRSCPayload,
- tree,
- ctx,
- reactServerErrorsByDigest.has((err as any).digest) ? undefined : err,
- errorType
- )
+ let prerenderHTTPError: PrerenderHTTPErrorState | undefined
+ if (cacheComponents && isHTTPAccessFallbackError(err)) {
+ const triggeredStatus = getAccessFallbackHTTPStatus(
+ err
+ ) as HTTPAccessErrorStatusCode
+ const boundaryTree = findPrerenderHTTPErrorBoundaryTree(
+ tree,
+ triggeredStatus,
+ ctx.renderOpts.experimental.authInterrupts
+ )
+
+ if (boundaryTree) {
+ prerenderHTTPError = {
+ boundaryTree,
+ triggeredStatus,
+ }
+ }
+ }
- const errorServerStream = workUnitAsyncStorage.run(
+ const errorRSCPayload = prerenderHTTPError
+ ? await workUnitAsyncStorage.run(
+ prerenderLegacyStore,
+ getRSCPayload,
+ tree,
+ ctx,
+ {
+ is404: errorType === 'not-found',
+ prerenderHTTPError,
+ }
+ )
+ : await workUnitAsyncStorage.run(
+ prerenderLegacyStore,
+ getErrorRSCPayload,
+ tree,
+ ctx,
+ reactServerErrorsByDigest.has((err as any).digest) ? undefined : err,
+ errorType
+ )
+
+ const errorServerStreamRaw = workUnitAsyncStorage.run(
prerenderLegacyStore,
renderToFlightStream,
ComponentMod,
@@ -7138,6 +7225,19 @@ async function prerenderToStream(
}
)
+ let errorServerStream = errorServerStreamRaw
+ const errorFlightResultPromise = prerenderHTTPError
+ ? (() => {
+ // Fizz still needs to read the Flight stream to render ErrorApp, but
+ // the prerender path also needs a buffered Flight result for the HTML
+ // prelude and segment data collectors. Tee the stream so each consumer
+ // gets its own copy.
+ const [appStream, flightStream] = teeStream(errorServerStreamRaw)
+ errorServerStream = appStream
+ return createReactServerPrerenderResultFromRender(flightStream)
+ })()
+ : null
+
try {
const { stream: errorHtmlStream } = await workUnitAsyncStorage.run(
prerenderLegacyStore,
@@ -7157,10 +7257,15 @@ async function prerenderToStream(
}
)
+ const resolvedFlightResult = errorFlightResultPromise
+ ? await errorFlightResultPromise
+ : reactServerPrerenderResult
+ if (errorFlightResultPromise) {
+ reactServerPrerenderResult.consume()
+ }
+
if (shouldGenerateStaticFlightData(workStore)) {
- const flightData = await streamToBuffer(
- reactServerPrerenderResult.asStream()
- )
+ const flightData = await streamToBuffer(resolvedFlightResult.asStream())
metadata.flightData = flightData
await collectSegmentData(
flightData,
@@ -7172,9 +7277,7 @@ async function prerenderToStream(
)
}
- // This is intentionally using the readable datastream from the main
- // render rather than the flight data from the error page render
- const flightStream = reactServerPrerenderResult.consumeAsStream()
+ const flightStream = resolvedFlightResult.consumeAsStream()
return {
digestErrorsMap: reactServerErrorsByDigest,
diff --git a/packages/next/src/server/app-render/create-component-tree.tsx b/packages/next/src/server/app-render/create-component-tree.tsx
index 3b9ff10615f46..5d27d62eeded6 100644
--- a/packages/next/src/server/app-render/create-component-tree.tsx
+++ b/packages/next/src/server/app-render/create-component-tree.tsx
@@ -44,6 +44,13 @@ import {
import type { AppSegmentConfig } from '../../build/segment-config/app/app-segment-config'
import { RenderStage, type StagedRenderingController } from './staged-rendering'
+type HTTPAccessErrorStatusCode = 404 | 403 | 401
+
+export type PrerenderHTTPErrorState = {
+ boundaryTree: LoaderTree
+ triggeredStatus: HTTPAccessErrorStatusCode
+}
+
/**
* Use the provided loader tree to create the React Component tree.
*/
@@ -62,6 +69,7 @@ export function createComponentTree(props: {
preloadCallbacks: PreloadCallbacks
authInterrupts: boolean
MetadataOutlet: ComponentType
+ prerenderHTTPError?: PrerenderHTTPErrorState
}): Promise<CacheNodeSeedData> {
return getTracer().trace(
NextNodeServerSpan.createComponentTree,
@@ -99,6 +107,7 @@ async function createComponentTreeInternal(
preloadCallbacks,
authInterrupts,
MetadataOutlet,
+ prerenderHTTPError,
}: {
loaderTree: LoaderTree
parentParams: Params
@@ -113,6 +122,7 @@ async function createComponentTreeInternal(
preloadCallbacks: PreloadCallbacks
authInterrupts: boolean
MetadataOutlet: ComponentType | null
+ prerenderHTTPError?: PrerenderHTTPErrorState
},
isRoot: boolean
): Promise<CacheNodeSeedData> {
@@ -595,28 +605,72 @@ async function createComponentTreeInternal(
}
}
- const seedData = await createComponentTreeInternal(
- {
- loaderTree: parallelRoute,
- parentParams: currentParams,
- parentOptionalCatchAllParamName: optionalCatchAllParamName,
- parentRuntimePrefetchable: isRuntimePrefetchable,
- rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove,
- injectedCSS: injectedCSSWithCurrentLayout,
- injectedJS: injectedJSWithCurrentLayout,
- injectedFontPreloadTags: injectedFontPreloadTagsWithCurrentLayout,
- ctx,
- missingSlots,
- preloadCallbacks,
- authInterrupts,
- // `StreamingMetadataOutlet` is used to conditionally throw. In the case of parallel routes we will have more than one page
- // but we only want to throw on the first one.
- MetadataOutlet: isChildrenRouteKey ? MetadataOutlet : null,
- },
- false
- )
+ // The outer prerender catch already found the deepest segment whose
+ // HTTP fallback should replace the throwing page. When we reach that
+ // segment's `children` slot, render the fallback directly instead of
+ // descending back into the subtree that threw during deserialization.
+
+ // Like the other segment-level boundary props below, HTTP access
+ // fallbacks are attached to the default `children` slot, not to named
+ // parallel routes.
+ const shouldRenderPrerenderHTTPFallback =
+ prerenderHTTPError?.boundaryTree === tree && isChildrenRouteKey
+
+ if (shouldRenderPrerenderHTTPFallback) {
+ let fallbackElement: React.ReactNode | undefined
+ switch (prerenderHTTPError.triggeredStatus) {
+ case 404:
+ fallbackElement = notFoundElement
+ break
+ case 403:
+ fallbackElement = forbiddenElement
+ break
+ case 401:
+ fallbackElement = unauthorizedElement
+ break
+ default:
+ break
+ }
- childCacheNodeSeedData = seedData
+ if (fallbackElement) {
+ childCacheNodeSeedData = createSeedData(
+ ctx,
+ fallbackElement,
+ {},
+ null,
+ isPossiblyPartialResponse,
+ false,
+ emptyVaryParamsAccumulator
+ )
+ }
+ }
+
+ if (childCacheNodeSeedData === null) {
+ const seedData = await createComponentTreeInternal(
+ {
+ loaderTree: parallelRoute,
+ parentParams: currentParams,
+ parentOptionalCatchAllParamName: optionalCatchAllParamName,
+ parentRuntimePrefetchable: isRuntimePrefetchable,
+ rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove,
+ injectedCSS: injectedCSSWithCurrentLayout,
+ injectedJS: injectedJSWithCurrentLayout,
+ injectedFontPreloadTags:
+ injectedFontPreloadTagsWithCurrentLayout,
+ ctx,
+ missingSlots,
+ preloadCallbacks,
+ authInterrupts,
+ // `StreamingMetadataOutlet` is used to conditionally throw. In the case of parallel routes we will have more than one page
+ // but we only want to throw on the first one.
+ MetadataOutlet: isChildrenRouteKey ? MetadataOutlet : null,
+ prerenderHTTPError,
+ },
+ false
+ )
+
+ childCacheNodeSeedData = seedData
+ }
}
const templateNode = createElement(
diff --git a/test/e2e/app-dir/cache-components/app/cases/not-found-suspense/layout.tsx b/test/e2e/app-dir/cache-components/app/cases/not-found-suspense/layout.tsx
new file mode 100644
index 0000000000000..624873b1a80f4
--- /dev/null
+++ b/test/e2e/app-dir/cache-components/app/cases/not-found-suspense/layout.tsx
@@ -0,0 +1,17 @@
+import { Suspense } from 'react'
+
+async function AsyncComponent() {
+ await new Promise<void>((resolve) => setTimeout(resolve, 100))
+ return <div id="async-data">Async Data Loaded</div>
+}
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+ <div>
+ {children}
+ <Suspense fallback={<div>Loading async...</div>}>
+ <AsyncComponent />
+ </Suspense>
+ </div>
+ )
+}
diff --git a/test/e2e/app-dir/cache-components/app/cases/not-found-suspense/not-found.tsx b/test/e2e/app-dir/cache-components/app/cases/not-found-suspense/not-found.tsx
new file mode 100644
index 0000000000000..499007bb9b42c
--- /dev/null
+++ b/test/e2e/app-dir/cache-components/app/cases/not-found-suspense/not-found.tsx
@@ -0,0 +1,3 @@
+export default function NotFound() {
+ return <div id="not-found-text">Custom 404 - Not Found</div>
+}
diff --git a/test/e2e/app-dir/cache-components/app/cases/not-found-suspense/page.tsx b/test/e2e/app-dir/cache-components/app/cases/not-found-suspense/page.tsx
new file mode 100644
index 0000000000000..3183f5adf2970
--- /dev/null
+++ b/test/e2e/app-dir/cache-components/app/cases/not-found-suspense/page.tsx
@@ -0,0 +1,7 @@
+import { notFound } from 'next/navigation'
+
+export default async function Page() {
+ notFound()
+
+ return <p>This will never render</p>
+}
diff --git a/test/e2e/app-dir/cache-components/cache-components.test.ts b/test/e2e/app-dir/cache-components/cache-components.test.ts
index dcf99e1b73a0b..de35cf6fa7b7d 100644
--- a/test/e2e/app-dir/cache-components/cache-components.test.ts
+++ b/test/e2e/app-dir/cache-components/cache-components.test.ts
@@ -64,6 +64,20 @@ describe('cache-components', () => {
}
})
+ it('should render not-found with Suspense in layout without connection errors', async () => {
+ const browser = await next.browser('/cases/not-found-suspense')
+
+ // The custom not-found component should render
+ expect(await browser.elementById('not-found-text').text()).toBe(
+ 'Custom 404 - Not Found'
+ )
+
+ // The async Suspense content in the layout should also render
+ expect(await browser.elementById('async-data').text()).toBe(
+ 'Async Data Loaded'
+ )
+ })
+
it('should prerender pages that render in a microtask', async () => {
let $ = await next.render$('/cases/microtask', {})
if (isNextDev) {