Information disclosure through leakage of server internals into browser/client bundles
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', () => {