Input validation / Parameter handling

MEDIUM
vercel/next.js
Commit: 78f73b27069f
Affected: Next.js 16.x up to and including 16.2.2 (prior to this fix)
2026-04-04 07:11 UTC

Description

The commit hardens dynamic route parameter handling by normalizing and validating encoded parameters. It adds a check to reject encoded default placeholders (e.g., %5BteamSlug%5D) for dynamic route params and ensures values are only considered valid if they are properly decoded and not placeholders. This prevents scenarios where encoded placeholders could bypass routing validation or be misinterpreted as real parameter values. The change consists of a code fix in server-utils.ts (normalizeDynamicRouteParams) plus regression tests and related e2e tests.

Proof of Concept

PoC outline to illustrate the vulnerability and the fix impact: 1) Scenario: An app uses a dynamic route /[teamSlug]/[project] and relies on dynamic route params for authorization or resource access checks. 2) Attacker attempt before the fix: Craft a request URL where the dynamic params are encoded placeholders, e.g. /%5BteamSlug%5D/%5Bproject%5D. The encoded values decode to the strings "[teamSlug]" and "[project]" which are placeholders, not real values. If the server-side validation previously treated such encoded placeholders as valid decoded values, the attacker could bypass certain parameter validation or route handling logic that expects real, concrete values. This could lead to information leakage, misrouting, or bypass of param-based access checks. 3) Proof-of-concept steps: - Use a Next.js app with a dynamic route [teamSlug]/[project] protected by a param-based check (e.g., only allow access if teamSlug === 'acme' and project === 'dashboard'). - Send a request to: curl -i "https://example.com/%5BteamSlug%5D/%5Bproject%5D" - Observe the server’s handling of the params. If the old behavior treated the encoded placeholders as valid decoded values and allowed access or routing to a placeholder path, you’d observe a match against the protected resource (e.g., a successful 200 response or access to a resource tied to placeholder values). 4) After the fix: The normalization now detects encoded placeholders and rejects them as invalid params, causing the request to be treated as having invalid parameters (likely resulting in a 400/404 rather than allowing access or misrouting). 5) Expected outcome of the PoC after the fix: The same encoded path /%5BteamSlug%5D/%5Bproject%5D yields a failed parameter validation (hasValidParams: false) and does not proceed to the protected route or resource. Note: The PoC demonstrates the vulnerability in terms of encoded placeholder bypass risk and shows how the fix prevents accepting such placeholders as valid params. The exact server response may vary by app authorization logic and error handling.

Commit Details

Author: JJ Kasper

Date: 2026-03-19 19:39 UTC

Message:

Handle encoded params further (#91627) Continues https://github.com/vercel/next.js/pull/91603 applying the encoding normalization further and narrows in a better regression test separate of root params and specific to vary params flag.

Triage Assessment

Vulnerability Type: Input validation / Parameter handling

Confidence: MEDIUM

Reasoning:

The patch adds logic to normalize and validate dynamic route params, specifically rejecting encoded default placeholders and ensuring only valid decoded values are accepted. This tightens input handling for route parameters and prevents scenarios where encoded placeholders could bypass routing validation or lead to injection-like issues. While not a classic vulnerability like XSS/SQLi, it improves security of dynamic param handling.

Verification Assessment

Vulnerability Type: Input validation / Parameter handling

Confidence: MEDIUM

Affected Versions: Next.js 16.x up to and including 16.2.2 (prior to this fix)

Code Diff

diff --git a/packages/next/src/server/server-utils.test.ts b/packages/next/src/server/server-utils.test.ts index e141dfe9c8121..e7a742539c49e 100644 --- a/packages/next/src/server/server-utils.test.ts +++ b/packages/next/src/server/server-utils.test.ts @@ -71,3 +71,134 @@ describe('getParamsFromRouteMatches', () => { expect(params).toEqual({ slug: 'hello-world', rest: ['im-the', 'rest'] }) }) }) + +describe('normalizeDynamicRouteParams', () => { + it('should reject encoded default placeholders for dynamic params', () => { + const { normalizeDynamicRouteParams } = getServerUtils({ + page: '/[teamSlug]/[project]', + basePath: '', + rewrites: {}, + i18n: undefined, + pageIsDynamic: true, + caseSensitive: false, + }) + + const result = normalizeDynamicRouteParams( + { + teamSlug: '%5BteamSlug%5D', + project: '%5Bproject%5D', + }, + true + ) + + expect(result).toEqual({ + params: {}, + hasValidParams: false, + }) + }) + + it('should reject doubly encoded default placeholders for dynamic params', () => { + const { normalizeDynamicRouteParams } = getServerUtils({ + page: '/[teamSlug]/[project]', + basePath: '', + rewrites: {}, + i18n: undefined, + pageIsDynamic: true, + caseSensitive: false, + }) + + const result = normalizeDynamicRouteParams( + { + teamSlug: '%255BteamSlug%255D', + project: '%255Bproject%255D', + }, + true + ) + + expect(result).toEqual({ + params: {}, + hasValidParams: false, + }) + }) + + it('should continue accepting regular dynamic values', () => { + const { normalizeDynamicRouteParams } = getServerUtils({ + page: '/[teamSlug]/[project]', + basePath: '', + rewrites: {}, + i18n: undefined, + pageIsDynamic: true, + caseSensitive: false, + }) + + const result = normalizeDynamicRouteParams( + { + teamSlug: 'vercel', + project: 'nextjs', + }, + true + ) + + expect(result).toEqual({ + params: { + teamSlug: 'vercel', + project: 'nextjs', + }, + hasValidParams: true, + }) + }) + + it('should not decode matched params beyond the route matcher decode', () => { + const { normalizeDynamicRouteParams } = getServerUtils({ + page: '/[teamSlug]/[project]', + basePath: '', + rewrites: {}, + i18n: undefined, + pageIsDynamic: true, + caseSensitive: false, + }) + + const result = normalizeDynamicRouteParams( + { + teamSlug: 'acme', + project: '%23hash', + }, + true + ) + + expect(result).toEqual({ + params: { + teamSlug: 'acme', + project: '%23hash', + }, + hasValidParams: true, + }) + }) + + it('should not reject non-placeholder values that only contain decoded placeholder text', () => { + const { normalizeDynamicRouteParams } = getServerUtils({ + page: '/[teamSlug]/[project]', + basePath: '', + rewrites: {}, + i18n: undefined, + pageIsDynamic: true, + caseSensitive: false, + }) + + const result = normalizeDynamicRouteParams( + { + teamSlug: 'acme', + project: '%5Bproject%5D-suffix', + }, + true + ) + + expect(result).toEqual({ + params: { + teamSlug: 'acme', + project: '%5Bproject%5D-suffix', + }, + hasValidParams: true, + }) + }) +}) diff --git a/packages/next/src/server/server-utils.ts b/packages/next/src/server/server-utils.ts index 7b0e9f7d0e645..3aaa4fdbbb8e3 100644 --- a/packages/next/src/server/server-utils.ts +++ b/packages/next/src/server/server-utils.ts @@ -112,6 +112,34 @@ export function normalizeDynamicRouteParams( defaultRouteMatches: ParsedUrlQuery, ignoreMissingOptional: boolean ) { + const isDefaultValueMatch = ( + candidateValue: string | undefined, + defaultValue: string + ) => { + if (!candidateValue) { + return false + } + + let normalizedCandidateValue = normalizeRscURL(candidateValue) + for (let i = 0; i < 3; i++) { + if (normalizedCandidateValue === defaultValue) { + return true + } + + const decodedCandidateValue = decodeQueryPathParameter( + normalizedCandidateValue + ) + + if (decodedCandidateValue === normalizedCandidateValue) { + break + } + + normalizedCandidateValue = decodedCandidateValue + } + + return false + } + let hasValidParams = true let params: ParsedUrlQuery = {} @@ -133,10 +161,12 @@ export function normalizeDynamicRouteParams( const isDefaultValue = Array.isArray(defaultValue) ? defaultValue.some((defaultVal) => { return Array.isArray(value) - ? value.some((val) => val.includes(defaultVal)) - : value?.includes(defaultVal) + ? value.some((val) => isDefaultValueMatch(val, defaultVal)) + : isDefaultValueMatch(value, defaultVal) }) - : value?.includes(defaultValue as string) + : Array.isArray(value) + ? value.some((val) => isDefaultValueMatch(val, defaultValue as string)) + : isDefaultValueMatch(value, defaultValue as string) if ( isDefaultValue || diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/loading.tsx b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/loading.tsx new file mode 100644 index 0000000000000..ea4c925adbf2a --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/loading.tsx @@ -0,0 +1,5 @@ +export default function TeamProjectLoading() { + return ( + <div data-team-project-loading="true">Loading team project page...</div> + ) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/page.tsx b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/page.tsx new file mode 100644 index 0000000000000..7772b61879eb3 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/[teamSlug]/[project]/page.tsx @@ -0,0 +1,34 @@ +import { cacheLife } from 'next/cache' +import { Suspense } from 'react' + +type Params = { teamSlug: string; project: string } + +export default function TeamProjectPage({ + params, +}: { + params: Promise<Params> +}) { + return ( + <div id="team-project-page"> + <Suspense + fallback={<div data-loading="true">Loading team/project route...</div>} + > + <TeamProjectContent params={params} /> + </Suspense> + </div> + ) +} + +async function TeamProjectContent({ params }: { params: Promise<Params> }) { + 'use cache' + cacheLife({ stale: 0, revalidate: 1, expire: 60 }) + + const { teamSlug, project } = await params + const marker = Date.now() + + return ( + <div data-team-project-content="true"> + {`Team project content - team: ${teamSlug}, project: ${project}, marker: ${marker}`} + </div> + ) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/layout.tsx b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/layout.tsx new file mode 100644 index 0000000000000..888614deda3ba --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + <html> + <body>{children}</body> + </html> + ) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/page.tsx b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/page.tsx new file mode 100644 index 0000000000000..143b9845dd083 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/app/page.tsx @@ -0,0 +1,25 @@ +import { LinkAccordion } from '../components/link-accordion' + +export default function HomePage() { + return ( + <div id="home-page"> + <h1>Root Dynamic Route Vary Params</h1> + <p> + Prefetch dynamic team/project routes and validate segment payload + params. + </p> + <ul> + <li> + <LinkAccordion href="/acme/dashboard"> + Team project: acme/dashboard + </LinkAccordion> + </li> + <li> + <LinkAccordion href="/globex/portal"> + Team project: globex/portal + </LinkAccordion> + </li> + </ul> + </div> + ) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/components/link-accordion.tsx b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/components/link-accordion.tsx new file mode 100644 index 0000000000000..6675b87162626 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/components/link-accordion.tsx @@ -0,0 +1,35 @@ +'use client' + +import Link, { type LinkProps } from 'next/link' +import { useState } from 'react' + +export function LinkAccordion({ + href, + children, + prefetch, +}: { + href: string + children?: React.ReactNode + prefetch?: LinkProps['prefetch'] +}) { + const [isVisible, setIsVisible] = useState(false) + const displayChildren = children !== undefined ? children : href + + return ( + <> + <input + type="checkbox" + checked={isVisible} + onChange={() => setIsVisible(!isVisible)} + data-link-accordion={href} + /> + {isVisible ? ( + <Link href={href} prefetch={prefetch}> + {displayChildren} + </Link> + ) : ( + <>{displayChildren} (link is hidden)</> + )} + </> + ) +} diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/next.config.js b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/next.config.js new file mode 100644 index 0000000000000..a5b5fedadb4e4 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/next.config.js @@ -0,0 +1,12 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + cacheComponents: true, + experimental: { + optimisticRouting: true, + varyParams: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/vary-params-base-dynamic.test.ts b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/vary-params-base-dynamic.test.ts new file mode 100644 index 0000000000000..da83c9f1d3ed8 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/vary-params-base-dynamic/vary-params-base-dynamic.test.ts @@ -0,0 +1,145 @@ +import { nextTestSetup } from 'e2e-utils' +import { waitFor } from 'next-test-utils' +import type * as Playwright from 'playwright' +import { createRouterAct } from 'router-act' + +describe('segment cache - vary params base dynamic', () => { + const { next, isNextDev } = nextTestSetup({ + files: __dirname, + }) + + if (isNextDev) { + test('prefetching is disabled in dev mode', () => {}) + return + } + + it('keeps dynamic segment params valid before and after time-based revalidation', async () => { + const collectSegmentPrefetchResponses = async (href: string) => { + let act: ReturnType<typeof createRouterAct> + const segmentPrefetchResponses: Array< + Promise<{ body: string; segmentPrefetchPath: string }> + > = [] + + const browser = await next.browser('/', { + beforePageLoad(p: Playwright.Page) { + act = createRouterAct(p) + p.on('response', (response) => { + const request = response.request() + const segmentPath = + request.headers()['next-router-segment-prefetch'] + + if (segmentPath) { + const pathname = new URL(request.url()).pathname + const segmentPrefetchPath = pathname.endsWith('.rsc') + ? `${pathname.slice(0, -'.rsc'.length)}.segments${segmentPath}.segment.rsc` + : `${pathname}.segments${segmentPath}.segment.rsc` + + segmentPrefetchResponses.push( + response + .text() + .then((body) => ({ body, segmentPrefetchPath })) + .catch(() => ({ body: '', segmentPrefetchPath })) + ) + } + }) + }, + }) + + await act(async () => { + const toggle = await browser.elementByCss( + `input[data-link-accordion="${href}"]` + ) + await toggle.click() + }) + + const settledResponses = await Promise.all(segmentPrefetchResponses) + await browser.close() + + return settledResponses + } + + const readRouteMarker = async (path: string, expectedText: string) => { + const browser = await next.browser(path) + const content = await browser.elementByCss('[data-team-project-content]') + const text = await content.text() + await browser.close() + + expect(text).toContain(expectedText) + const markerMatch = text.match(/marker: (\d+)/) + expect(markerMatch).not.toBeNull() + return Number(markerMatch![1]) + } + + const assertValidSegmentResponses = ( + responses: Array<{ body: string; segmentPrefetchPath: string }> + ) => { + const bodies = responses.map((response) => response.body) + const allBodies = bodies.join('\n') + const segmentPrefetchPaths = [ + ...new Set(responses.map((response) => response.segmentPrefetchPath)), + ] + + expect(bodies.length).toBeGreaterThan(0) + expect(allBodies.includes('%5BteamSlug%5D')).toBe(false) + expect(allBodies.includes('%5Bproject%5D')).toBe(false) + expect( + segmentPrefetchPaths.some((path) => + path.startsWith('/acme/dashboard.segments/') + ) + ).toBe(true) + expect( + segmentPrefetchPaths.some((path) => + path.startsWith('/globex/portal.segments/') + ) + ).toBe(true) + expect( + segmentPrefetchPaths.every( + (path) => path.includes('.segments/') && path.endsWith('.segment.rsc') + ) + ).toBe(true) + } + + const initialAcmeMarker = await readRouteMarker( + '/acme/dashboard', + 'Team project content - team: acme, project: dashboard' + ) + const initialGlobexMarker = await readRouteMarker( + '/globex/portal', + 'Team project content - team: globex, project: portal' + ) + + const initialResponses = [ + ...(await collectSegmentPrefetchResponses('/acme/dashboard')), + ...(await collectSegmentPrefetchResponses('/globex/portal')), + ] + assertValidSegmentResponses(initialResponses) + + let lastAcmeMarker = initialAcmeMarker + let lastGlobexMarker = initialGlobexMarker + + for (let checkIndex = 0; checkIndex < 5; checkIndex++) { + await waitFor(2_000) + + const revalidatedResponses = [ + ...(await collectSegmentPrefetchResponses('/acme/dashboard')), + ...(await collectSegmentPrefetchResponses('/globex/p ... [truncated]
← Back to Alerts View on GitHub →