Information disclosure

MEDIUM
vercel/next.js
Commit: 85f9231929c9
Affected: < 16.2.2
2026-06-29 19:32 UTC

Description

Summary: The commit implements a hardening to prevent server-only modules (notably the server-side implementation of unstable_rethrow) from being bundled into client-browser builds. Prior to this change, the client browser bundle could include server-oriented code paths (e.g., unstable_rethrow.server.ts and related server rendering checks), which risked information disclosure by exposing server internals to the client. The fix removes the server-specific module from the client bundle, introduces a browser-specific variant (unstable-rethrow.browser) and maps the client context to that variant, and deletes the server-only file. This reduces the attack surface by ensuring server internals are not present in client bundles. The triage notes align with this being a boundary/infosec hardening rather than a direct vulnerability in application logic, but it guards against information disclosure via bundled server code. What changed (high-level): - Deleted unstable-rethrow.server.ts and updated unstable-rethrow.ts to document that the browser bundle uses a browser-safe version and that the server logic should not reside in client bundles. - Updated import resolution/mapping to alias to a browser variant for client contexts (e.g., unstable-rethrow.browser) via get_next_client_resolved_map and other import-map style changes. - Adjusted tests to ensure server modules are not bundled into browser chunks. Security impact: By preventing server-only code from leaking into the client bundle, the risk of exposing server internals (error handling logic, server-side checks, dynamic rendering details, etc.) is reduced. This is categorized as Information disclosure related to server internals being surfaced to the client. The fix improves isolation between client and server code paths and reduces potential reconnaissance for attackers. Affected area: The Next.js client-side build and module resolution for unstable_rethrow and related server-side error handling, particularly around how server checks are or are not included in client bundles.

Proof of Concept

Proof-of-concept (PoC) steps to observe the vulnerability before the patch and validate the fix: 1) Reproduce on pre-fix behavior (simulated, since you may not have the exact pre-patch environment): - Build a Next.js app that uses unstable_rethrow in client code (a client component calling unstable_rethrow on an error path). - Inspect the client bundle(s) in the deployed app (e.g., _next/static/chunks/*.js) and search for server-side code references, such as unstable_rethrow.server.ts or server-only helper functions (isDynamicServerError, dynamic rendering utilities, etc.). - Confirm that the browser bundle contains server-oriented checks or server-only paths, which could expose internal server logic to clients. 2) PoC script to verify server code is present in the client bundle (pre-fix): - Objective: Detect server-only code in browser bundles and surface its path in a developer-friendly way. - How: Scan the built browser chunks for strings that indicate server-only logic (e.g., unstable_rethrow.server, isDynamicServerError, dynamic-rendering utilities). Example Node.js snippet to detect server code in bundles: ```js // PoC: detect server-side code in Next.js browser bundles const fs = require('fs'); const path = require('path'); const glob = require('glob'); // Adjust to your Next.js build output path const bundlesPath = path.resolve(__dirname, '.next', 'static', 'chunks'); glob(path.join(bundlesPath, '**', '*.js'), {}, (err, files) => { if (err) throw err; const hits = files.filter((f) => { try { const content = fs.readFileSync(f, 'utf8'); return ( content.includes('unstable_rethrow.server') || content.includes('isDynamicServerError') || content.includes('unstable-rethrow.server') || content.includes('server rendering') ); } catch { return false; } }); if (hits.length) { console.log('Server-in-bundle indicators found in:', hits); } else { console.log('No server-in-bundle indicators found in bundles.'); } }); ``` 3) Validate fix (post-patch in 16.2.2+): - Re-run the same scan against the patched build output. - Expectation: No matches for server-only indicators (unstable_rethrow.server, isDynamicServerError, etc.) in client bundles. The client should reference only the browser-safe variant unstable_rethrow.browser where applicable. 4) Optional manual verification: - Open a page that uses unstable_rethrow in a client component and trigger an error flow that would have previously hit server-specific checks. - Observe that the client-side error handling path no longer exposes server internals, and the browser console shows no server-specific error handling logic leaking into client code.

Commit Details

Author: Sebastian "Sebbie" Silbermann

Date: 2026-06-29 13:00 UTC

Message:

Avoid bundling client-nodemodules used by `unstable_rethrow` in client-browser modules (#95196) There are more client-node modules being bundled into `client-browser` which I'll fix in a followup (with codegen). Our internal `unstable-rethrow` will now have a version without suffix (unbundled or client-node) and a client-browser only version (`.browser`) that bundlers alias to for client-browser which will always be bundled. For the specific modules being removed from client-browser, check the second commit with the actual change. The first commit fixes the `browser-chunk` test to include `next/src/server` in a snapshot. Closes #95189

Triage Assessment

Vulnerability Type: Information disclosure

Confidence: MEDIUM

Reasoning:

The commit changes the bundling behavior to ensure server-only modules (unstable_rethrow server logic) are not included in client-browser bundles by mapping to a browser-safe variant and removing the server implementation. This reduces the risk of server internals leaking into the client bundle, which could otherwise expose sensitive logic or surface areas to the client.

Verification Assessment

Vulnerability Type: Information disclosure

Confidence: MEDIUM

Affected Versions: < 16.2.2

Code Diff

diff --git a/crates/next-core/src/next_client/context.rs b/crates/next-core/src/next_client/context.rs index 89920725dce1..dde485b0140d 100644 --- a/crates/next-core/src/next_client/context.rs +++ b/crates/next-core/src/next_client/context.rs @@ -162,6 +162,7 @@ pub async fn get_client_resolve_options_context( .await?; let next_client_resolved_map = get_next_client_resolved_map(project_path.clone(), project_path.clone(), *mode.await?) + .await? .to_resolved() .await?; let mut custom_conditions: Vec<_> = mode.await?.custom_resolve_conditions().collect(); diff --git a/crates/next-core/src/next_import_map.rs b/crates/next-core/src/next_import_map.rs index 3003510d7592..a623412388ea 100644 --- a/crates/next-core/src/next_import_map.rs +++ b/crates/next-core/src/next_import_map.rs @@ -7,7 +7,11 @@ use next_taskless::{EDGE_NODE_EXTERNALS, NODE_EXTERNALS}; use rustc_hash::FxHashMap; use turbo_rcstr::{RcStr, rcstr}; use turbo_tasks::{FxIndexMap, ResolvedVc, Vc, fxindexmap}; -use turbo_tasks_fs::{FileContent, FileSystem, FileSystemPath, to_sys_path}; +use turbo_tasks_fs::{ + FileContent, FileSystem, FileSystemPath, + glob::{Glob, GlobOptions}, + to_sys_path, +}; use turbopack_core::{ asset::AssetContent, issue::{Issue, IssueExt, IssueSeverity, IssueStage, StyledString}, @@ -559,16 +563,36 @@ async fn insert_unsupported_node_internal_aliases(import_map: &mut ImportMap) -> Ok(()) } -pub fn get_next_client_resolved_map( - _context: FileSystemPath, - _root: FileSystemPath, +pub async fn get_next_client_resolved_map( + context_path: FileSystemPath, + root: FileSystemPath, _mode: NextMode, -) -> Vc<ResolvedMap> { - let glob_mappings = vec![]; - ResolvedMap { +) -> Result<Vc<ResolvedMap>> { + // In the browser bundle, swap the default `unstable-rethrow` (which holds the full + // server logic) for its `.browser` sibling. The server-only checks can never occur in + // the browser, and bundling the default would drag server-only modules into the client + // bundle. This is the Turbopack analog of the webpack alias in `create-compiler-aliases.ts` + // and is client-only because `get_next_client_resolved_map` is used only by the client + // context. Matching is on the resolved file path, so it intercepts the relative import + // regardless of which module pulls it in. Anchored at the filesystem root so it matches + // wherever `next` resolves from (node_modules, pnpm store, or monorepo `packages/next`). + let glob_mappings = vec![( + root.root().owned().await?, + Glob::new( + rcstr!("**/next/dist/client/components/unstable-rethrow.js"), + GlobOptions::default(), + ) + .to_resolved() + .await?, + request_to_import_mapping( + context_path, + rcstr!("next/dist/client/components/unstable-rethrow.browser"), + ), + )]; + Ok(ResolvedMap { by_glob: glob_mappings, } - .cell() + .cell()) } static NEXT_ALIASES: LazyLock<[(RcStr, RcStr); 23]> = LazyLock::new(|| { diff --git a/packages/next/src/client/components/unstable-rethrow.server.ts b/packages/next/src/client/components/unstable-rethrow.server.ts deleted file mode 100644 index 4db3679f3286..000000000000 --- a/packages/next/src/client/components/unstable-rethrow.server.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { isHangingPromiseRejectionError } from '../../server/dynamic-rendering-utils' -import { isPostpone } from '../../server/lib/router-utils/is-postpone' -import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr' -import { isNextRouterError } from './is-next-router-error' -import { - isDynamicPostpone, - isPrerenderInterruptedError, -} from '../../server/app-render/dynamic-rendering' -import { isDynamicServerError } from './hooks-server-context' - -export function unstable_rethrow(error: unknown): void { - if ( - isNextRouterError(error) || - isBailoutToCSRError(error) || - isDynamicServerError(error) || - isDynamicPostpone(error) || - isPostpone(error) || - isHangingPromiseRejectionError(error) || - isPrerenderInterruptedError(error) - ) { - throw error - } - - if (error instanceof Error && 'cause' in error) { - unstable_rethrow(error.cause) - } -} diff --git a/packages/next/src/client/components/unstable-rethrow.ts b/packages/next/src/client/components/unstable-rethrow.ts index de82d998bbdc..a56a175fc072 100644 --- a/packages/next/src/client/components/unstable-rethrow.ts +++ b/packages/next/src/client/components/unstable-rethrow.ts @@ -1,15 +1,39 @@ +import { isHangingPromiseRejectionError } from '../../server/dynamic-rendering-utils' +import { isPostpone } from '../../server/lib/router-utils/is-postpone' +import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr' +import { isNextRouterError } from './is-next-router-error' +import { + isDynamicPostpone, + isPrerenderInterruptedError, +} from '../../server/app-render/dynamic-rendering' +import { isDynamicServerError } from './hooks-server-context' + /** * This function should be used to rethrow internal Next.js errors so that they can be handled by the framework. * When wrapping an API that uses errors to interrupt control flow, you should use this function before you do any error handling. * This function will rethrow the error if it is a Next.js error so it can be handled, otherwise it will do nothing. * + * In the browser bundle this module is aliased to `./unstable-rethrow.browser`, which performs a + * subset of these checks (the server-only ones can never occur in the browser). This default + * module holds the full server logic and is used on every server runtime (Node, edge) and in any + * context where the alias does not apply. + * * Read more: [Next.js Docs: `unstable_rethrow`](https://nextjs.org/docs/app/api-reference/functions/unstable_rethrow) */ -export const unstable_rethrow = - typeof window === 'undefined' - ? ( - require('./unstable-rethrow.server') as typeof import('./unstable-rethrow.server') - ).unstable_rethrow - : ( - require('./unstable-rethrow.browser') as typeof import('./unstable-rethrow.browser') - ).unstable_rethrow +export function unstable_rethrow(error: unknown): void { + if ( + isNextRouterError(error) || + isBailoutToCSRError(error) || + isDynamicServerError(error) || + isDynamicPostpone(error) || + isPostpone(error) || + isHangingPromiseRejectionError(error) || + isPrerenderInterruptedError(error) + ) { + throw error + } + + if (error instanceof Error && 'cause' in error) { + unstable_rethrow(error.cause) + } +} diff --git a/test/production/app-dir/browser-chunks/browser-chunks.test.ts b/test/production/app-dir/browser-chunks/browser-chunks.test.ts index 1e5391386e2f..e94e1cf3f889 100644 --- a/test/production/app-dir/browser-chunks/browser-chunks.test.ts +++ b/test/production/app-dir/browser-chunks/browser-chunks.test.ts @@ -3,6 +3,22 @@ import { join } from 'path' import { readFile } from 'fs/promises' import { listClientChunks } from 'next-test-utils' +// Normalize a sourcemap `sources` entry to a path relative to the Next.js +// package root (e.g. `src/...`, `dist/...`), so the path filters below behave +// consistently across bundlers and produce stable snapshots. Webpack emits +// `webpack://_N_E/../../src/...`; Turbopack emits +// `turbopack:///[project]/<...>/packages/next/src/...`. +function normalizeSource(source: string): string { + return source + .replace(/\?.*$/, '') + .replace(/^webpack:\/\/_N_E\//, '') + .replace(/^turbopack:\/\/\//, '') + .replace(/^\[project\]\//, '') + .replace(/^(?:\.\.\/)+/, '') + .replace(/^.*?packages\/next\//, '') + .replace(/^.*?\/node_modules\/next\//, '') +} + describe('browser-chunks', () => { const { next } = nextTestSetup({ files: __dirname, @@ -29,68 +45,150 @@ describe('browser-chunks', () => { ) }) + // These snapshots document which matching modules currently reach browser + // chunks. Some of these we don't intend to act on yet, so we snapshot the + // normalized paths (rather than hard-fail) to surface regressions on review. it('must not bundle any server modules into browser chunks', () => { - const serverSources = sources.filter( - (source) => - /webpack:\/\/_N_E\/(\.\.\/)*src\/server\//.test(source) || - source.includes('next/dist/esm/server') || - source.includes('next/dist/server') || - source.includes('next-devtools/server') - ) - - if (serverSources.length > 0) { - console.error( - `Found the following server modules:\n ${serverSources.join('\n ')}\nIf any of these modules are allowed to be included in browser chunks, move them to src/shared or src/client.` + const serverSources = Array.from( + new Set( + sources + .map(normalizeSource) + .filter( + (source) => + source.startsWith('src/server/') || + source.startsWith('dist/esm/server/') || + source.startsWith('dist/server/') || + source.includes('next-devtools/server') + ) ) - - throw new Error('Did not expect any server modules in browser chunks.') + ).sort() + + // This set varies along two axes, so snapshot each combination separately + // rather than forcing them to agree: + // - bundler: webpack's browser chunks contain none of these; Turbopack + // still pulls in a set we haven't acted on yet. + // - cache components: enabling it pulls additional server modules + // (instant-validation, async storage, dynamic rendering) into the + // client render path. CI runs this suite both with and without it + // (see test/cache-components-tests-manifest.json). + const cacheComponents = process.env.__NEXT_CACHE_COMPONENTS === 'true' + if (process.env.IS_TURBOPACK_TEST) { + if (cacheComponents) { + expect(serverSources).toMatchInlineSnapshot(` + [ + "src/server/app-render/action-async-storage-instance.ts", + "src/server/app-render/action-async-storage.external.ts", + "src/server/app-render/after-task-async-storage-instance.ts", + "src/server/app-render/after-task-async-storage.external.ts", + "src/server/app-render/async-local-storage.ts", + "src/server/app-render/blocking-route-messages.ts", + "src/server/app-render/dynamic-access-async-storage-instance.ts", + "src/server/app-render/dynamic-access-async-storage.external.ts", + "src/server/app-render/dynamic-rendering.ts", + "src/server/app-render/instant-validation/boundary-constants.ts", + "src/server/app-render/instant-validation/boundary-impl.tsx", + "src/server/app-render/instant-validation/boundary-tracking.tsx", + "src/server/app-render/instant-validation/instant-samples-client.ts", + "src/server/app-render/instant-validation/instant-samples.ts", + "src/server/app-render/instant-validation/instant-validation-error.ts", + "src/server/app-render/staged-rendering.ts", + "src/server/app-render/vary-params.ts", + "src/server/app-render/work-async-storage-instance.ts", + "src/server/app-render/work-async-storage.external.ts", + "src/server/app-render/work-unit-async-storage-instance.ts", + "src/server/app-render/work-unit-async-storage.external.ts", + "src/server/create-deduped-by-callsite-server-error-logger.ts", + "src/server/dynamic-rendering-utils.ts", + "src/server/request/params.ts", + "src/server/request/search-params.ts", + "src/server/request/utils.ts", + "src/server/runtime-reacts.external.ts", + "src/server/web/spec-extension/adapters/headers.ts", + "src/server/web/spec-extension/adapters/reflect.ts", + "src/server/web/spec-extension/adapters/request-cookies.ts", + "src/server/web/spec-extension/cookies.ts", + ] + `) + } else { + expect(serverSources).toMatchInlineSnapshot(` + [ + "src/server/app-render/action-async-storage-instance.ts", + "src/server/app-render/action-async-storage.external.ts", + "src/server/app-render/after-task-async-storage-instance.ts", + "src/server/app-render/after-task-async-storage.external.ts", + "src/server/app-render/async-local-storage.ts", + "src/server/app-render/blocking-route-messages.ts", + "src/server/app-render/dynamic-access-async-storage-instance.ts", + "src/server/app-render/dynamic-access-async-storage.external.ts", + "src/server/app-render/dynamic-rendering.ts", + "src/server/app-render/instant-validation/boundary-constants.ts", + "src/server/app-render/instant-validation/boundary-tracking.tsx", + "src/server/app-render/instant-validation/instant-samples.ts", + "src/server/app-render/instant-validation/instant-validation-error.ts", + "src/server/app-render/staged-rendering.ts", + "src/server/app-render/vary-params.ts", + "src/server/app-render/work-async-storage-instance.ts", + "src/server/app-render/work-async-storage.external.ts", + "src/server/app-render/work-unit-async-storage-instance.ts", + "src/server/app-render/work-unit-async-storage.external.ts", + "src/server/create-deduped-by-callsite-server-error-logger.ts", + "src/server/dynamic-rendering-utils.ts", + "src/server/request/params.ts", + "src/server/request/search-params.ts", + "src/server/request/utils.ts", + "src/server/runtime-reacts.external.ts", + "src/server/web/spec-extension/adapters/headers.ts", + "src/server/web/spec-extension/adapters/reflect.ts", + "src/server/web/spec-extension/adapters/request-cookies.ts", + "src/server/web/spec-extension/cookies.ts", + ] + `) + } + } else { + if (cacheComponents) { + expect(serverSources).toMatchInlineSnapshot(`[]`) + } else { + expect(serverSources).toMatchInlineSnapshot(`[]`) + } } }) it('must not bundle any dev overlay into browser chunks', () => { - const devOverlaySources = sources.filter((source) => { - return source.includes('next-devtools') - }) - - if (devOverlaySources.length > 0) { - const message = `Found the following dev overlay modules:\n ${devOverlaySources.join('\n')}` - console.error( - `${message}\nIf any of these modules are allowed to be included in production chunks, check the import and render conditions.` + const devOverlay ... [truncated]
← Back to Alerts View on GitHub →