Information disclosure

MEDIUM
vercel/next.js
Commit: 3ddfcace6636
Affected: 16.2.2 and earlier (16.2 release line); fixed in a subsequent release after this commit
2026-04-04 05:44 UTC

Description

The commit fixes a potential information disclosure by normalizing encoded dynamic placeholders in app routes. Previously, segments like %5BprojectSlug%5D could be misinterpreted as literal path segments when parsing app route segments or during fallback/segment prefetch, which could leak or reveal internal dynamic parameter names (e.g., root param placeholders) via prefetch responses. The fix decodes encoded placeholders and checks whether the decoded segment represents a dynamic parameter (e.g., [param]); if so, it preserves it as a dynamic segment, otherwise it leaves the segment as-is. This prevents encoded placeholders from leaking param names and ensures correct dynamic-parameter handling in prefetch and route parsing. Tests were added to guard encoded placeholders in both fallback-params parsing and segment-prefetch responses.

Proof of Concept

PoC (conceptual, local setup required): 1) Start a Next.js app using the app directory with a route structure like /[teamSlug]/[projectSlug]. 2) Trigger router prefetch for an encoded child segment (before fix this might leak the placeholder): curl -s http://localhost:3000/vercel/%5BprojectSlug%5D -H "next-router-segment-prefetch: 1" | head 3) Observe the response body or any prefetch-related payload containing the encoded placeholder (e.g., %5BprojectSlug%5D or [projectSlug]). This demonstrates leakage prior to normalization. After applying the fix, the encoded placeholder should be treated as a dynamic parameter, and the prefetch payload should no longer expose the placeholder name. A minimal Node.js PoC you can run against a local server: const fetch = require('node-fetch'); (async () => { const res = await fetch('http://localhost:3000/vercel/%5BprojectSlug%5D', { headers: { 'next-router-segment-prefetch': '1' } }) const text = await res.text() console.log(text) })() Alternatively, a curl version: curl -s http://localhost:3000/vercel/%5BprojectSlug%5D -H "next-router-segment-prefetch: 1"

Commit Details

Author: JJ Kasper

Date: 2026-03-18 21:36 UTC

Message:

Normalize encoded dynamic placeholders in app routes (#91603) ## Summary - normalize encoded dynamic placeholders like `%5Bproject%5D` before parsing app route segments - add a fallback params regression test covering encoded placeholder inputs - assert vary-param segment prefetch responses do not contain encoded root param placeholders ## Testing - pnpm testheadless packages/next/src/server/request/fallback-params.test.ts - pnpm testheadless test/e2e/app-dir/segment-cache/vary-params/vary-params.test.ts -t "tracks root param access via rootParams API"

Triage Assessment

Vulnerability Type: Information disclosure

Confidence: MEDIUM

Reasoning:

The change normalizes encoded dynamic placeholders in app routes to ensure encoded segments are correctly treated as dynamic parameters rather than literal path segments. This prevents potential leakage or misinterpretation of route parameters (e.g., root param placeholders) during prefetching and route parsing, which could expose parameter names or allow edge-case bypasses. Tests explicitly guard against encoded placeholders being exposed in prefetch responses.

Verification Assessment

Vulnerability Type: Information disclosure

Confidence: MEDIUM

Affected Versions: 16.2.2 and earlier (16.2 release line); fixed in a subsequent release after this commit

Code Diff

diff --git a/packages/next/src/server/request/fallback-params.test.ts b/packages/next/src/server/request/fallback-params.test.ts index 9b1187be02cd4..ee4117367fabe 100644 --- a/packages/next/src/server/request/fallback-params.test.ts +++ b/packages/next/src/server/request/fallback-params.test.ts @@ -190,6 +190,24 @@ describe('getFallbackRouteParams', () => { expect(result!.has('projectSlug')).toBe(true) expect(result!.has('teamSlug')).toBe(false) }) + + it('should treat encoded placeholders as dynamic segments', () => { + // Tree: /[teamSlug]/[projectSlug] but page is /vercel/%5BprojectSlug%5D + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[teamSlug]', {}, createLoaderTree('[projectSlug]')) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams( + '/vercel/%5BprojectSlug%5D', + routeModule + ) + + expect(result).not.toBeNull() + expect(result!.has('projectSlug')).toBe(true) + expect(result!.has('teamSlug')).toBe(false) + }) }) describe('Route Groups', () => { diff --git a/packages/next/src/shared/lib/router/routes/app.ts b/packages/next/src/shared/lib/router/routes/app.ts index 0112713fa0fed..fba29a29041b8 100644 --- a/packages/next/src/shared/lib/router/routes/app.ts +++ b/packages/next/src/shared/lib/router/routes/app.ts @@ -60,6 +60,19 @@ export type NormalizedAppRouteSegment = | StaticAppRouteSegment | DynamicAppRouteSegment +function normalizeEncodedDynamicPlaceholder(segment: string): string { + if (!/%5b|%5d/i.test(segment)) { + return segment + } + + try { + const decodedSegment = decodeURIComponent(segment) + return getSegmentParam(decodedSegment) ? decodedSegment : segment + } catch { + return segment + } +} + export function parseAppRouteSegment(segment: string): AppRouteSegment | null { if (segment === '') { return null @@ -180,8 +193,10 @@ export function parseAppRoute( let interceptedRoute: AppRoute | NormalizedAppRoute | undefined for (const segment of pathnameSegments) { + const normalizedSegment = normalizeEncodedDynamicPlaceholder(segment) + // Parse the segment into an AppSegment. - const appSegment = parseAppRouteSegment(segment) + const appSegment = parseAppRouteSegment(normalizedSegment) if (!appSegment) { continue } diff --git a/test/e2e/app-dir/segment-cache/vary-params/root-params-segment-prefetch.test.ts b/test/e2e/app-dir/segment-cache/vary-params/root-params-segment-prefetch.test.ts new file mode 100644 index 0000000000000..1826252faa96b --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params/root-params-segment-prefetch.test.ts @@ -0,0 +1,61 @@ +import { nextTestSetup } from 'e2e-utils' +import type * as Playwright from 'playwright' +import { createRouterAct } from 'router-act' + +describe('segment cache - root params segment prefetch', () => { + const { next, isNextDev } = nextTestSetup({ + files: __dirname, + }) + + if (isNextDev) { + test('prefetching is disabled in dev mode', () => {}) + return + } + + it('does not encode root param placeholders in segment-prefetch responses', async () => { + let act: ReturnType<typeof createRouterAct> + const segmentPrefetchBodies: Array<Promise<string>> = [] + const browser = await next.browser('/root-params', { + beforePageLoad(p: Playwright.Page) { + act = createRouterAct(p) + p.on('response', (response) => { + const request = response.request() + if (request.headers()['next-router-segment-prefetch']) { + segmentPrefetchBodies.push(response.text().catch(() => '')) + } + }) + }, + }) + + await act( + async () => { + const toggle = await browser.elementByCss( + 'input[data-link-accordion="/aaa"]' + ) + await toggle.click() + }, + { includes: 'Root param page content - param: aaa' } + ) + + await act( + async () => { + const toggle = await browser.elementByCss( + 'input[data-link-accordion="/bbb"]' + ) + await toggle.click() + }, + { includes: 'Root param page content - param: bbb' } + ) + + const settledSegmentPrefetchBodies = await Promise.all( + segmentPrefetchBodies + ) + + expect(settledSegmentPrefetchBodies.length).toBeGreaterThan(0) + expect( + settledSegmentPrefetchBodies.some((body) => + body.includes('%5BrootParam%5D') + ) + ).toBe(false) + }) +})
← Back to Alerts View on GitHub →