Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
generateSafeRegExpCode,
generateMiddlewareMatcherCode,
generateNormalizePathCode,
generateRouteMatchNormalizationCode,
} from "../server/middleware-codegen.js";
import { isProxyFile } from "../server/middleware.js";

Expand Down Expand Up @@ -1291,6 +1292,7 @@ ${generateSafeRegExpCode("modern")}

// ── Path normalization ──────────────────────────────────────────────────
${generateNormalizePathCode("modern")}
${generateRouteMatchNormalizationCode("modern")}

// ── Config pattern matching, redirects, rewrites, headers, CSRF validation,
// external URL proxy, cookie parsing, and request context are imported from
Expand Down Expand Up @@ -1424,7 +1426,7 @@ export default async function handler(request, ctx) {
if (__configHeaders.length) {
const url = new URL(request.url);
let pathname;
try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; }
try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; }
${bp ? `if (pathname.startsWith(${JSON.stringify(bp)})) pathname = pathname.slice(${JSON.stringify(bp)}.length) || "/";` : ""}
const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
for (const h of extraHeaders) {
Expand Down Expand Up @@ -1473,11 +1475,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const __protoGuard = guardProtocolRelativeUrl(url.pathname);
if (__protoGuard) return __protoGuard;

// Decode percent-encoding and normalize pathname to canonical form.
// decodeURIComponent prevents /%61dmin from bypassing /admin matchers.
// Decode percent-encoding segment-wise and normalize pathname to canonical form.
// This preserves encoded path delimiters like %2F within a single segment.
// __normalizePath collapses //foo///bar → /foo/bar, resolves . and .. segments.
let decodedUrlPathname;
try { decodedUrlPathname = decodeURIComponent(url.pathname); } catch (e) {
try { decodedUrlPathname = __normalizePathnameForRouteMatchStrict(url.pathname); } catch (e) {
return new Response("Bad Request", { status: 400 });
}
let pathname = __normalizePath(decodedUrlPathname);
Expand Down
4 changes: 3 additions & 1 deletion packages/vinext/src/entries/pages-server-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
generateSafeRegExpCode,
generateMiddlewareMatcherCode,
generateNormalizePathCode,
generateRouteMatchNormalizationCode,
} from "../server/middleware-codegen.js";
import { findFileWithExts } from "./pages-entry-helpers.js";

Expand Down Expand Up @@ -149,6 +150,7 @@ import { NextRequest, NextFetchEvent } from "next/server";`
? `
// --- Middleware support (generated from middleware-codegen.ts) ---
${generateNormalizePathCode("es5")}
${generateRouteMatchNormalizationCode("es5")}
${generateSafeRegExpCode("es5")}
${generateMiddlewareMatcherCode("es5")}

Expand All @@ -175,7 +177,7 @@ async function _runMiddleware(request) {
// Normalize pathname before matching to prevent path-confusion bypasses
// (percent-encoding like /%61dmin, double slashes like /dashboard//settings).
var decodedPathname;
try { decodedPathname = decodeURIComponent(url.pathname); } catch (e) {
try { decodedPathname = __normalizePathnameForRouteMatchStrict(url.pathname); } catch (e) {
return { continue: false, response: new Response("Bad Request", { status: 400 }) };
}
var normalizedPathname = __normalizePath(decodedPathname);
Expand Down
3 changes: 2 additions & 1 deletion packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { createDirectRunner } from "./server/dev-module-runner.js";
import { generateRscEntry } from "./entries/app-rsc-entry.js";
import { generateSsrEntry } from "./entries/app-ssr-entry.js";
import { generateBrowserEntry } from "./entries/app-browser-entry.js";
import { normalizePathnameForRouteMatchStrict } from "./routing/utils.js";
import {
loadNextConfig,
resolveNextConfig,
Expand Down Expand Up @@ -1999,7 +2000,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
// decodeURIComponent prevents /%61dmin bypassing /admin matchers.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: stale comment. The code no longer calls decodeURIComponent directly — it uses normalizePathnameForRouteMatchStrict. Consider updating to match the new behavior:

Suggested change
// decodeURIComponent prevents /%61dmin bypassing /admin matchers.
// Segment-wise decoding prevents /%61dmin bypassing /admin matchers
// while preserving encoded path delimiters like %2F within segments.

// normalizePath collapses // and resolves . / .. segments.
try {
pathname = normalizePath(decodeURIComponent(pathname));
pathname = normalizePath(normalizePathnameForRouteMatchStrict(pathname));
} catch {
// Malformed percent-encoding (e.g. /%E0%A4%A) — return 400 instead of crashing.
res.writeHead(400);
Expand Down
14 changes: 3 additions & 11 deletions packages/vinext/src/routing/app-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/
import path from "node:path";
import fs from "node:fs";
import { compareRoutes } from "./utils.js";
import { compareRoutes, decodeRouteSegment, normalizePathnameForRouteMatch } from "./utils.js";
import {
createValidFileMatcher,
scanWithExtensions,
Expand Down Expand Up @@ -988,11 +988,7 @@ function convertSegmentsToRouteParts(
continue;
}

try {
urlSegments.push(decodeURIComponent(segment));
} catch {
urlSegments.push(segment);
}
urlSegments.push(decodeRouteSegment(segment));
}

return { urlSegments, params, isDynamic };
Expand Down Expand Up @@ -1035,11 +1031,7 @@ export function matchAppRoute(
): { route: AppRoute; params: Record<string, string | string[]> } | null {
const pathname = url.split("?")[0];
let normalizedUrl = pathname === "/" ? "/" : pathname.replace(/\/$/, "");
try {
normalizedUrl = decodeURIComponent(normalizedUrl);
} catch {
/* malformed percent-encoding — match as-is */
}
normalizedUrl = normalizePathnameForRouteMatch(normalizedUrl);

// Split URL once, look up via trie
const urlParts = normalizedUrl.split("/").filter(Boolean);
Expand Down
10 changes: 3 additions & 7 deletions packages/vinext/src/routing/pages-router.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from "node:path";
import { compareRoutes } from "./utils.js";
import { compareRoutes, decodeRouteSegment, normalizePathnameForRouteMatch } from "./utils.js";
import {
createValidFileMatcher,
scanWithExtensions,
Expand Down Expand Up @@ -142,7 +142,7 @@ function fileToRoute(file: string, pagesDir: string, matcher: ValidFileMatcher):
continue;
}

urlSegments.push(segment);
urlSegments.push(decodeRouteSegment(segment));
}

const pattern = "/" + urlSegments.join("/");
Expand Down Expand Up @@ -179,11 +179,7 @@ export function matchRoute(
// Normalize: strip query string and trailing slash
const pathname = url.split("?")[0];
let normalizedUrl = pathname === "/" ? "/" : pathname.replace(/\/$/, "");
try {
normalizedUrl = decodeURIComponent(normalizedUrl);
} catch {
/* malformed percent-encoding — match as-is */
}
normalizedUrl = normalizePathnameForRouteMatch(normalizedUrl);

// Split URL once, look up via trie
const urlParts = normalizedUrl.split("/").filter(Boolean);
Expand Down
52 changes: 52 additions & 0 deletions packages/vinext/src/routing/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,55 @@ export function compareRoutes<T extends { pattern: string }>(a: T, b: T): number
const diff = routePrecedence(a.pattern) - routePrecedence(b.pattern);
return diff !== 0 ? diff : a.pattern.localeCompare(b.pattern);
}

// Matches literal delimiter characters and their percent-encoded equivalents.
// Literal `/`, `#`, `?` can appear after decodeURIComponent when the input was
// originally encoded (e.g. `%2F` → `/`); they are re-encoded to preserve their
// role as delimiters. `\` is included to handle both `%5C` and Windows-style
// path separators that may appear in filesystem-derived route segments.
const PATH_DELIMITER_REGEX = /([/#?\\]|%(2f|23|3f|5c))/gi;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This regex matches literal #, ?, \ characters in addition to their percent-encoded forms. After decodeURIComponent, the only way a literal # or ? can appear in a URL segment is if it was originally %23 or %3F — browsers/HTTP clients don't put literal #/? in path segments. So the regex is correct and handles the decode-then-re-encode round-trip properly.

However, the backslash case is worth noting: literal \ in a decoded segment could come from either %5C or from Windows-style path separators leaking into route discovery. The \%5C re-encoding is correct for the URL matching path, but consider adding a brief comment noting that \ is included to handle Windows path separators in filesystem-derived segments (not just percent-encoded backslashes).


function encodePathDelimiters(segment: string): string {
return segment.replace(PATH_DELIMITER_REGEX, (char) => encodeURIComponent(char));
}

/**
* Decode a filesystem or URL path segment while preserving encoded path delimiters.
* Mirrors Next.js segment-wise decoding so "%5F" becomes "_" but "%2F" stays "%2F".
*/
export function decodeRouteSegment(segment: string): string {
try {
return encodePathDelimiters(decodeURIComponent(segment));
} catch {
return segment;
}
}

/**
* Strict variant for request pipelines that should reject malformed percent-encoding.
*/
export function decodeRouteSegmentStrict(segment: string): string {
return encodePathDelimiters(decodeURIComponent(segment));
}

/**
* Normalize a pathname for route matching by decoding each segment independently.
* This prevents encoded slashes from turning into real path separators.
*/
export function normalizePathnameForRouteMatch(pathname: string): string {
return pathname
.split("/")
.map((segment) => decodeRouteSegment(segment))
.join("/");
}

/**
* Strict pathname normalization for live request handling.
* Throws on malformed percent-encoding so callers can return 400.
*/
export function normalizePathnameForRouteMatchStrict(pathname: string): string {
return pathname
.split("/")
.map((segment) => decodeRouteSegmentStrict(segment))
.join("/");
}
40 changes: 40 additions & 0 deletions packages/vinext/src/server/middleware-codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,46 @@ function __normalizePath(pathname) {
}`;
}

/**
* Returns generated JavaScript source for route-path normalization that
* preserves encoded path delimiters within a single segment.
*
* This mirrors decodeRouteSegment()/normalizePathnameForRouteMatch() in
* routing/utils.ts so "%5F" becomes "_" while "%2F" remains "%2F".
*
* @param style - "modern" emits const/let, "es5" emits var
*/
export function generateRouteMatchNormalizationCode(style: "modern" | "es5" = "modern"): string {
const v = style === "modern" ? "const" : "var";
const l = style === "modern" ? "let" : "var";
return `
${v} __pathDelimiterRegex = /([/#?\\\\]|%(2f|23|3f|5c))/gi;
function __decodeRouteSegment(segment) {
return decodeURIComponent(segment).replace(__pathDelimiterRegex, function (char) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: codegen __decodeRouteSegment is missing error handling present in the runtime version

The runtime decodeRouteSegment in routing/utils.ts wraps decodeURIComponent in a try/catch and returns the original segment on failure. The codegen version here does not — it will throw on malformed percent-encoding.

This difference matters at the config headers call site:

try { pathname = __normalizePath(__normalizePathnameForRouteMatch(url.pathname)); } catch { pathname = url.pathname; }

If one segment is malformed (e.g. /good/%E0%A4%A/also-good), the runtime would successfully decode the good segments and pass through the bad one. The codegen version will throw on the bad segment, causing the outer catch to fall back to the entire raw pathname — no segments get decoded. This is a minor behavioral inconsistency but could produce different header matching results between dev (which uses the runtime) and prod/codegen.

For the strict call sites (returning 400), this doesn't matter. But the non-strict codegen path should match the runtime's lenient behavior.

Suggested change
return decodeURIComponent(segment).replace(__pathDelimiterRegex, function (char) {
return decodeURIComponent(segment).replace(__pathDelimiterRegex, function (char) {
return encodeURIComponent(char);
});
}
function __decodeRouteSegmentSafe(segment) {
try { return __decodeRouteSegment(segment); } catch (e) { return segment; }
}

Then use __decodeRouteSegmentSafe in __normalizePathnameForRouteMatch. The strict callers that want to throw on malformed input already wrap the whole thing in try/catch, so they'd still get the 400 behavior — but the config headers path would get proper segment-level degradation.

return encodeURIComponent(char);
});
}
function __decodeRouteSegmentSafe(segment) {
try { return __decodeRouteSegment(segment); } catch (e) { return segment; }
}
function __normalizePathnameForRouteMatch(pathname) {
${v} segments = pathname.split("/");
${v} normalized = [];
for (${l} i = 0; i < segments.length; i++) {
normalized.push(__decodeRouteSegmentSafe(segments[i]));
}
return normalized.join("/");
}
function __normalizePathnameForRouteMatchStrict(pathname) {
${v} segments = pathname.split("/");
${v} normalized = [];
for (${l} i = 0; i < segments.length; i++) {
normalized.push(__decodeRouteSegment(segments[i]));
}
return normalized.join("/");
}`;
Comment on lines +138 to +166
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: codegen only generates a safe/lenient normalizer, but two call sites depend on it throwing for malformed input.

The codegen emits __normalizePathnameForRouteMatch which uses __decodeRouteSegmentSafe — this swallows all decodeURIComponent errors and returns the raw segment. That's fine for the config headers path (app-rsc-entry.ts:1426) where the catch fallback is lenient.

But two other call sites wrap it in try/catch expecting it to throw on malformed percent-encoding:

  • app-rsc-entry.ts:1479: try { decodedUrlPathname = __normalizePathnameForRouteMatch(url.pathname); } catch (e) { return new Response("Bad Request", { status: 400 }); }
  • pages-server-entry.ts:176: try { decodedPathname = __normalizePathnameForRouteMatch(url.pathname); } catch (e) { return { continue: false, response: new Response("Bad Request", { status: 400 }) }; }

Since __normalizePathnameForRouteMatch never throws, malformed paths like /%E0%A4%A will pass through instead of returning 400. This is a behavioral divergence from the runtime, where normalizePathnameForRouteMatchStrict (used in middleware.ts, prod-server.ts, and index.ts) correctly throws.

The fix is to also generate a strict variant in the codegen:

Suggested change
export function generateRouteMatchNormalizationCode(style: "modern" | "es5" = "modern"): string {
const v = style === "modern" ? "const" : "var";
const l = style === "modern" ? "let" : "var";
return `
${v} __pathDelimiterRegex = /([/#?\\\\]|%(2f|23|3f|5c))/gi;
function __decodeRouteSegment(segment) {
return decodeURIComponent(segment).replace(__pathDelimiterRegex, function (char) {
return encodeURIComponent(char);
});
}
function __decodeRouteSegmentSafe(segment) {
try { return __decodeRouteSegment(segment); } catch (e) { return segment; }
}
function __normalizePathnameForRouteMatch(pathname) {
${v} segments = pathname.split("/");
${v} normalized = [];
for (${l} i = 0; i < segments.length; i++) {
normalized.push(__decodeRouteSegmentSafe(segments[i]));
}
return normalized.join("/");
}`;
export function generateRouteMatchNormalizationCode(style: "modern" | "es5" = "modern"): string {
const v = style === "modern" ? "const" : "var";
const l = style === "modern" ? "let" : "var";
return `
${v} __pathDelimiterRegex = /([/#?\\\\]|%(2f|23|3f|5c))/gi;
function __decodeRouteSegment(segment) {
return decodeURIComponent(segment).replace(__pathDelimiterRegex, function (char) {
return encodeURIComponent(char);
});
}
function __decodeRouteSegmentSafe(segment) {
try { return __decodeRouteSegment(segment); } catch (e) { return segment; }
}
function __normalizePathnameForRouteMatch(pathname) {
${v} segments = pathname.split("/");
${v} normalized = [];
for (${l} i = 0; i < segments.length; i++) {
normalized.push(__decodeRouteSegmentSafe(segments[i]));
}
return normalized.join("/");
}
function __normalizePathnameForRouteMatchStrict(pathname) {
${v} segments = pathname.split("/");
${v} normalized = [];
for (${l} i = 0; i < segments.length; i++) {
normalized.push(__decodeRouteSegment(segments[i]));
}
return normalized.join("/");
}`;
}

Then update the request-handling call sites in app-rsc-entry.ts:1479 and pages-server-entry.ts:176 to use __normalizePathnameForRouteMatchStrict instead. The config headers call site at app-rsc-entry.ts:1426 should keep using the lenient __normalizePathnameForRouteMatch.

}

/**
* Returns the generated JavaScript source for middleware pattern matching.
*
Expand Down
3 changes: 2 additions & 1 deletion packages/vinext/src/server/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type { HasCondition, NextI18nConfig } from "../config/next-config.js";
import { NextRequest, NextFetchEvent } from "../shims/server.js";
import { normalizePath } from "./normalize-path.js";
import { shouldKeepMiddlewareHeader } from "./middleware-request-headers.js";
import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js";

/**
* Determine whether a middleware/proxy file path refers to a proxy file.
Expand Down Expand Up @@ -372,7 +373,7 @@ export async function runMiddleware(
// via percent-encoding (/%61dmin → /admin) or double slashes (/dashboard//settings).
let decodedPathname: string;
try {
decodedPathname = decodeURIComponent(url.pathname);
decodedPathname = normalizePathnameForRouteMatchStrict(url.pathname);
} catch {
// Malformed percent-encoding (e.g. /%E0%A4%A) — return 400 instead of throwing.
return { continue: false, response: new Response("Bad Request", { status: 400 }) };
Expand Down
5 changes: 3 additions & 2 deletions packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { normalizePath } from "./normalize-path.js";
import { hasBasePath, stripBasePath } from "../utils/base-path.js";
import { computeLazyChunks } from "../index.js";
import { manifestFileWithBase } from "../utils/manifest-paths.js";
import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js";
import type { ExecutionContextLike } from "../shims/request-context.js";

/** Convert a Node.js IncomingMessage into a ReadableStream for Web Request body. */
Expand Down Expand Up @@ -612,7 +613,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
const rawPathname = rawUrl.split("?")[0].replaceAll("\\", "/");
let pathname: string;
try {
pathname = normalizePath(decodeURIComponent(rawPathname));
pathname = normalizePath(normalizePathnameForRouteMatchStrict(rawPathname));
} catch {
// Malformed percent-encoding (e.g. /%E0%A4%A) — return 400 instead of crashing.
res.writeHead(400);
Expand Down Expand Up @@ -788,7 +789,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
const rawQs = rawUrl.includes("?") ? rawUrl.slice(rawUrl.indexOf("?")) : "";
let pathname: string;
try {
pathname = normalizePath(decodeURIComponent(rawPagesPathname));
pathname = normalizePath(normalizePathnameForRouteMatchStrict(rawPagesPathname));
} catch {
// Malformed percent-encoding (e.g. /%E0%A4%A) — return 400 instead of crashing.
res.writeHead(400);
Expand Down
Loading
Loading