Information disclosure through leakage of server internals into browser/client bundles

HIGH
vercel/next.js
Commit: ea16ee224c44
Affected: <=16.2.1 (i.e., versions prior to 16.2.2)
2026-07-08 07:29 UTC

Description

This commit implements browser-variant splitting to prevent server-only modules from leaking into client/browser bundles. Prior to this change, certain server-side modules (e.g., server/app-render/* and related instant-validation code) could be pulled into the browser bundle via dynamic requires or lack of proper .browser.ts/.browser.tsx separation, enabling potential information disclosure about server internals (module paths, server boundaries, implementation details) through the client bundle. The patch introduces explicit browser-variant modules, moves server-specific logic to client-safe variants, and updates imports to ensure browser bundles do not include server internals. It also removes a prior browser boundary implementation that relied on server-bound logic and replaces it with a browser-safe impl, along with a browser-only stub for certain validation boundary pieces. Overall, this is a hardening fix to reduce leakage of server internals into client payloads.

Proof of Concept

PoC outline (conceptual, require a vulnerable environment to execute): 1) Preconditions - A Next.js 16.2.x app built with 16.2.1 or earlier, with __NEXT_CACHE_COMPONENTS enabled (or otherwise triggering server-bound modules to be included in browser chunks). - The app includes server-side instant-validation/instant-samples code that historically could be resolved in browser chunks. 2) Build and inspect vulnerable bundle - Build the app in production mode with the vulnerable version. - Retrieve the browser JavaScript bundle(s) from the deployed app, e.g. a chunk under _next/static/chunks/ or _next/static/js/main.*.js. - Search the bundle for server-side module paths or identifiers such as: - src/server/app-render/instant-validation/boundary-impl.tsx - src/server/app-render/instant-validation/instant-samples-client.ts - src/server/app-render/instant-validation/instant-samples.ts - any path containing "/server/" or "src/server/" markers that should not be present in client code. - If such strings are present, the bundle is leaking server internals into the client payload. 3) Exploit vector (informational disclosure) - An attacker visiting the site could inspect the served JS bundle (via browser devtools) and extract server internals, such as module paths and server-side boundaries, potentially aiding targeted reconnaissance or attack planning against the server architecture. 4) Mitigation check - After applying the fix (16.2.2+ with this commit), rebuild and re-fetch the same bundles and search for the same server-side module paths. - Result should be an absence of server-internal paths in the client bundles, with imports/exports replaced by browser-safe variants (e.g., .browser.ts/.browser.tsx variants) and the browser-variant modules listed in BROWSER_VARIANT_MODULES. Example quick validation command (conceptual): # On vulnerable version curl -s https://your-app.example.com/_next/static/chunks/pages/index.js | grep -E "src/server|server/app-render|instant-validation" || echo "No server paths found" # On patched version curl -s https://your-app.example.com/_next/static/chunks/pages/index.js | grep -E "src/server|server/app-render|instant-validation" || echo "No server paths found" If the vulnerable bundle prints server paths and the patched bundle does not, the fix is effective.

Commit Details

Author: Sebastian "Sebbie" Silbermann

Date: 2026-07-08 07:14 UTC

Message:

Split remaining "client-node"-only modules into .browser variants (#95366) Closes https://linear.app/vercel/issue/NAR-860/ Resolves all recent regressions of introducing `next/src/server` into client-browser bundles.

Triage Assessment

Vulnerability Type: Information disclosure

Confidence: HIGH

Reasoning:

The patch moves server-side modules out of browser/client bundles and enforces browser-variant splits to prevent server code from leaking into client bundles. This mitigates information disclosure and exposure of server internals through client-side bundles, addressing a security risk rather than a purely architectural or performance change.

Verification Assessment

Vulnerability Type: Information disclosure through leakage of server internals into browser/client bundles

Confidence: HIGH

Affected Versions: <=16.2.1 (i.e., versions prior to 16.2.2)

Code Diff

diff --git a/crates/next-core/src/browser_variant_modules.rs b/crates/next-core/src/browser_variant_modules.rs index 1e1b9295b49a..133ef25ace48 100644 --- a/crates/next-core/src/browser_variant_modules.rs +++ b/crates/next-core/src/browser_variant_modules.rs @@ -11,6 +11,8 @@ #[rustfmt::skip] pub static BROWSER_VARIANT_MODULES: &[&str] = &[ "client/components/client-boundary-params", + "client/components/instant-samples", + "client/components/instant-validation/impl", "client/components/navigation-dynamic-rendering", "client/components/server-async-storage", "client/components/unstable-rethrow", diff --git a/packages/next/src/client/components/instant-samples.browser.ts b/packages/next/src/client/components/instant-samples.browser.ts new file mode 100644 index 000000000000..35c2fbf65ad7 --- /dev/null +++ b/packages/next/src/client/components/instant-samples.browser.ts @@ -0,0 +1,3 @@ +export const instrumentParamsForClientValidation = undefined +export const expectCompleteParamsInClientValidation = undefined +export const instrumentSearchParamsForClientValidation = undefined diff --git a/packages/next/src/server/app-render/instant-validation/instant-samples-client.ts b/packages/next/src/client/components/instant-samples.ts similarity index 88% rename from packages/next/src/server/app-render/instant-validation/instant-samples-client.ts rename to packages/next/src/client/components/instant-samples.ts index 15101787cb7c..7bd30ba8b1c1 100644 --- a/packages/next/src/server/app-render/instant-validation/instant-samples-client.ts +++ b/packages/next/src/client/components/instant-samples.ts @@ -1,13 +1,13 @@ -import type { Params } from '../../request/params' -import type { ReadonlyURLSearchParams } from '../../../client/components/readonly-url-search-params' -import { workUnitAsyncStorage } from '../work-unit-async-storage.external' -import { workAsyncStorage } from '../work-async-storage.external' +import type { Params } from '../../server/request/params' +import type { ReadonlyURLSearchParams } from './readonly-url-search-params' +import { workUnitAsyncStorage } from '../../server/app-render/work-unit-async-storage.external' +import { workAsyncStorage } from '../../server/app-render/work-async-storage.external' import { createExhaustiveParamsProxy, createExhaustiveURLSearchParamsProxy, trackMissingSampleErrorAndThrow, -} from './instant-samples' -import { InstantValidationError } from './instant-validation-error' +} from '../../server/app-render/instant-validation/instant-samples' +import { InstantValidationError } from '../../server/app-render/instant-validation/instant-validation-error' export function instrumentParamsForClientValidation<TPArams extends Params>( underlyingParams: TPArams diff --git a/packages/next/src/client/components/instant-validation/boundary.tsx b/packages/next/src/client/components/instant-validation/boundary.tsx index cf39318c3a06..8ed8ccd7fe8d 100644 --- a/packages/next/src/client/components/instant-validation/boundary.tsx +++ b/packages/next/src/client/components/instant-validation/boundary.tsx @@ -1,29 +1,9 @@ 'use client' - -// This facade ensures that the boundary code is DCE'd in browser bundles. -// -// It also exists to satisfy `browser-chunks.test.ts`, which looks for -// references to code in `packages/next/src/server` in browser bundles and errors if it finds any. -// A "use client" module seems to always have always have an entry in the browser bundle, -// so this module cannot be colocated with the rest of the instant validation code, -// because it ends up looking like it's importing server code in the browser -// even though all the server code inside is actually DCE'd. - -const { - InstantValidationBoundaryContext, - PlaceValidationBoundaryBelowThisLevel, - RenderValidationBoundaryAtThisLevel, - SlotMarker, -} = - typeof window === 'undefined' && process.env.__NEXT_CACHE_COMPONENTS - ? // TODO(browser-variant): migrate to a .ts/.browser.ts split so the browser bundle drops the server branch; see scripts/generate-browser-variant-aliases.mjs - // ast-grep-ignore: no-typeof-window-require-tsx - (require('../../../server/app-render/instant-validation/boundary-impl') as typeof import('../../../server/app-render/instant-validation/boundary-impl')) - : ({} as typeof import('../../../server/app-render/instant-validation/boundary-impl')) - +// We can't fork a `use client` boundary based on node-client vs browser-client. +// We need to fork one level deeper. export { InstantValidationBoundaryContext, PlaceValidationBoundaryBelowThisLevel, RenderValidationBoundaryAtThisLevel, SlotMarker, -} +} from './impl' diff --git a/packages/next/src/client/components/instant-validation/impl.browser.tsx b/packages/next/src/client/components/instant-validation/impl.browser.tsx new file mode 100644 index 000000000000..fad404980773 --- /dev/null +++ b/packages/next/src/client/components/instant-validation/impl.browser.tsx @@ -0,0 +1,4 @@ +export const InstantValidationBoundaryContext = null +export const PlaceValidationBoundaryBelowThisLevel = null +export const RenderValidationBoundaryAtThisLevel = null +export const SlotMarker = null diff --git a/packages/next/src/client/components/instant-validation/impl.tsx b/packages/next/src/client/components/instant-validation/impl.tsx new file mode 100644 index 000000000000..62e291f209df --- /dev/null +++ b/packages/next/src/client/components/instant-validation/impl.tsx @@ -0,0 +1,6 @@ +export { + InstantValidationBoundaryContext, + PlaceValidationBoundaryBelowThisLevel, + RenderValidationBoundaryAtThisLevel, + SlotMarker, +} from '../../../server/app-render/instant-validation/boundary-impl' diff --git a/packages/next/src/client/components/layout-router.tsx b/packages/next/src/client/components/layout-router.tsx index efdd8d91873e..52019013c5bd 100644 --- a/packages/next/src/client/components/layout-router.tsx +++ b/packages/next/src/client/components/layout-router.tsx @@ -33,6 +33,10 @@ import { ErrorBoundary } from './error-boundary' import { disableSmoothScrollDuringRouteTransition } from '../../shared/lib/router/utils/disable-smooth-scroll' import { RedirectBoundary } from './redirect-boundary' import { HTTPAccessFallbackBoundary } from './http-access-fallback/error-boundary' +import { + InstantValidationBoundaryContext, + RenderValidationBoundaryAtThisLevel, +} from './instant-validation/boundary' import { createRouterCacheKey } from './router-reducer/create-router-cache-key' import { useRouterBFCache, @@ -670,10 +674,6 @@ export default function OuterLayoutRouter({ let maybeValidationBoundaryId: string | null = null if (typeof window === 'undefined' && process.env.__NEXT_CACHE_COMPONENTS) { - const { InstantValidationBoundaryContext } = - // TODO(browser-variant): migrate to a .ts/.browser.ts split so the browser bundle drops the server branch; see scripts/generate-browser-variant-aliases.mjs - // ast-grep-ignore: no-typeof-window-require-tsx - require('./instant-validation/boundary') as typeof import('./instant-validation/boundary') maybeValidationBoundaryId = use(InstantValidationBoundaryContext) } @@ -812,10 +812,6 @@ export default function OuterLayoutRouter({ process.env.__NEXT_CACHE_COMPONENTS && typeof maybeValidationBoundaryId === 'string' ) { - const { RenderValidationBoundaryAtThisLevel } = - // TODO(browser-variant): migrate to a .ts/.browser.ts split so the browser bundle drops the server branch; see scripts/generate-browser-variant-aliases.mjs - // ast-grep-ignore: no-typeof-window-require-tsx - require('./instant-validation/boundary') as typeof import('./instant-validation/boundary') templateValue = ( <RenderValidationBoundaryAtThisLevel id={maybeValidationBoundaryId}> {templateValue} diff --git a/packages/next/src/client/components/navigation.ts b/packages/next/src/client/components/navigation.ts index 0c7dc84d3271..2e1df203d82a 100644 --- a/packages/next/src/client/components/navigation.ts +++ b/packages/next/src/client/components/navigation.ts @@ -27,12 +27,9 @@ const { instrumentParamsForClientValidation, instrumentSearchParamsForClientValidation, expectCompleteParamsInClientValidation, -} = - typeof window === 'undefined' && process.env.__NEXT_CACHE_COMPONENTS - ? // TODO(browser-variant): migrate to a .ts/.browser.ts split so the browser bundle drops the server branch; see scripts/generate-browser-variant-aliases.mjs - // ast-grep-ignore: no-typeof-window-require - (require('../../server/app-render/instant-validation/instant-samples-client') as typeof import('../../server/app-render/instant-validation/instant-samples-client')) - : {} +} = process.env.__NEXT_CACHE_COMPONENTS + ? (require('./instant-samples') as typeof import('./instant-samples')) + : {} /** * A [Client Component](https://nextjs.org/docs/app/building-your-application/rendering/client-components) hook 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 041a813ad02e..1299a9e82b5e 100644 --- a/test/production/app-dir/browser-chunks/browser-chunks.test.ts +++ b/test/production/app-dir/browser-chunks/browser-chunks.test.ts @@ -45,13 +45,11 @@ 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 = Array.from( new Set( sources + // normalizing in case we regress and want to keep track of the regression .map(normalizeSource) .filter( (source) => @@ -63,46 +61,7 @@ describe('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/async-local-storage.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/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/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/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(`[]`) - } - } else { - if (cacheComponents) { - expect(serverSources).toMatchInlineSnapshot(`[]`) - } else { - expect(serverSources).toMatchInlineSnapshot(`[]`) - } - } + expect(serverSources).toEqual([]) }) it('must not bundle any dev overlay into browser chunks', () => {
← Back to Alerts View on GitHub →