diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index e350d78d..099e9fca 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -22,6 +22,7 @@ import { generateSafeRegExpCode, generateMiddlewareMatcherCode, generateNormalizePathCode, + generateRouteMatchNormalizationCode, } from "../server/middleware-codegen.js"; import { isProxyFile } from "../server/middleware.js"; @@ -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 @@ -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) { @@ -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); diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 1c1cf408..fb452f29 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -17,6 +17,7 @@ import { generateSafeRegExpCode, generateMiddlewareMatcherCode, generateNormalizePathCode, + generateRouteMatchNormalizationCode, } from "../server/middleware-codegen.js"; import { findFileWithExts } from "./pages-entry-helpers.js"; @@ -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")} @@ -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); diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index bd67ff7b..414dcb97 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -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, @@ -1999,7 +2000,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // decodeURIComponent prevents /%61dmin bypassing /admin matchers. // 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); diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index e6f5b2a8..29753aec 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -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, @@ -988,11 +988,7 @@ function convertSegmentsToRouteParts( continue; } - try { - urlSegments.push(decodeURIComponent(segment)); - } catch { - urlSegments.push(segment); - } + urlSegments.push(decodeRouteSegment(segment)); } return { urlSegments, params, isDynamic }; @@ -1035,11 +1031,7 @@ export function matchAppRoute( ): { route: AppRoute; params: Record } | 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); diff --git a/packages/vinext/src/routing/pages-router.ts b/packages/vinext/src/routing/pages-router.ts index 51395eb6..43d0ec5b 100644 --- a/packages/vinext/src/routing/pages-router.ts +++ b/packages/vinext/src/routing/pages-router.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { compareRoutes } from "./utils.js"; +import { compareRoutes, decodeRouteSegment, normalizePathnameForRouteMatch } from "./utils.js"; import { createValidFileMatcher, scanWithExtensions, @@ -142,7 +142,7 @@ function fileToRoute(file: string, pagesDir: string, matcher: ValidFileMatcher): continue; } - urlSegments.push(segment); + urlSegments.push(decodeRouteSegment(segment)); } const pattern = "/" + urlSegments.join("/"); @@ -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); diff --git a/packages/vinext/src/routing/utils.ts b/packages/vinext/src/routing/utils.ts index a81b4bd2..005515c4 100644 --- a/packages/vinext/src/routing/utils.ts +++ b/packages/vinext/src/routing/utils.ts @@ -78,3 +78,55 @@ export function compareRoutes(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; + +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("/"); +} diff --git a/packages/vinext/src/server/middleware-codegen.ts b/packages/vinext/src/server/middleware-codegen.ts index 0accf9ed..48bdb645 100644 --- a/packages/vinext/src/server/middleware-codegen.ts +++ b/packages/vinext/src/server/middleware-codegen.ts @@ -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) { + 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("/"); +}`; +} + /** * Returns the generated JavaScript source for middleware pattern matching. * diff --git a/packages/vinext/src/server/middleware.ts b/packages/vinext/src/server/middleware.ts index 231a1c54..ac200df8 100644 --- a/packages/vinext/src/server/middleware.ts +++ b/packages/vinext/src/server/middleware.ts @@ -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. @@ -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 }) }; diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index b7c5f7bb..49f6f9dd 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -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. */ @@ -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); @@ -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); diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index f1be806d..c853dc15 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1541,6 +1541,32 @@ function __normalizePath(pathname) { return "/" + resolved.join("/"); } +const __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) { + const segments = pathname.split("/"); + const normalized = []; + for (let i = 0; i < segments.length; i++) { + normalized.push(__decodeRouteSegmentSafe(segments[i])); + } + return normalized.join("/"); +} +function __normalizePathnameForRouteMatchStrict(pathname) { + const segments = pathname.split("/"); + const normalized = []; + for (let i = 0; i < segments.length; i++) { + normalized.push(__decodeRouteSegment(segments[i])); + } + return normalized.join("/"); +} + // ── Config pattern matching, redirects, rewrites, headers, CSRF validation, // external URL proxy, cookie parsing, and request context are imported from // config-matchers.ts and request-pipeline.ts (see import statements above). @@ -1666,7 +1692,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; } const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); for (const h of extraHeaders) { @@ -1715,11 +1741,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); @@ -4287,6 +4313,32 @@ function __normalizePath(pathname) { return "/" + resolved.join("/"); } +const __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) { + const segments = pathname.split("/"); + const normalized = []; + for (let i = 0; i < segments.length; i++) { + normalized.push(__decodeRouteSegmentSafe(segments[i])); + } + return normalized.join("/"); +} +function __normalizePathnameForRouteMatchStrict(pathname) { + const segments = pathname.split("/"); + const normalized = []; + for (let i = 0; i < segments.length; i++) { + normalized.push(__decodeRouteSegment(segments[i])); + } + return normalized.join("/"); +} + // ── Config pattern matching, redirects, rewrites, headers, CSRF validation, // external URL proxy, cookie parsing, and request context are imported from // config-matchers.ts and request-pipeline.ts (see import statements above). @@ -4412,7 +4464,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; } if (pathname.startsWith("/base")) pathname = pathname.slice("/base".length) || "/"; const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); for (const h of extraHeaders) { @@ -4461,11 +4513,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); @@ -7066,6 +7118,32 @@ function __normalizePath(pathname) { return "/" + resolved.join("/"); } +const __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) { + const segments = pathname.split("/"); + const normalized = []; + for (let i = 0; i < segments.length; i++) { + normalized.push(__decodeRouteSegmentSafe(segments[i])); + } + return normalized.join("/"); +} +function __normalizePathnameForRouteMatchStrict(pathname) { + const segments = pathname.split("/"); + const normalized = []; + for (let i = 0; i < segments.length; i++) { + normalized.push(__decodeRouteSegment(segments[i])); + } + return normalized.join("/"); +} + // ── Config pattern matching, redirects, rewrites, headers, CSRF validation, // external URL proxy, cookie parsing, and request context are imported from // config-matchers.ts and request-pipeline.ts (see import statements above). @@ -7191,7 +7269,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; } const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); for (const h of extraHeaders) { @@ -7240,11 +7318,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); @@ -9849,6 +9927,32 @@ function __normalizePath(pathname) { return "/" + resolved.join("/"); } +const __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) { + const segments = pathname.split("/"); + const normalized = []; + for (let i = 0; i < segments.length; i++) { + normalized.push(__decodeRouteSegmentSafe(segments[i])); + } + return normalized.join("/"); +} +function __normalizePathnameForRouteMatchStrict(pathname) { + const segments = pathname.split("/"); + const normalized = []; + for (let i = 0; i < segments.length; i++) { + normalized.push(__decodeRouteSegment(segments[i])); + } + return normalized.join("/"); +} + // ── Config pattern matching, redirects, rewrites, headers, CSRF validation, // external URL proxy, cookie parsing, and request context are imported from // config-matchers.ts and request-pipeline.ts (see import statements above). @@ -9977,7 +10081,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; } const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); for (const h of extraHeaders) { @@ -10026,11 +10130,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); @@ -12605,6 +12709,32 @@ function __normalizePath(pathname) { return "/" + resolved.join("/"); } +const __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) { + const segments = pathname.split("/"); + const normalized = []; + for (let i = 0; i < segments.length; i++) { + normalized.push(__decodeRouteSegmentSafe(segments[i])); + } + return normalized.join("/"); +} +function __normalizePathnameForRouteMatchStrict(pathname) { + const segments = pathname.split("/"); + const normalized = []; + for (let i = 0; i < segments.length; i++) { + normalized.push(__decodeRouteSegment(segments[i])); + } + return normalized.join("/"); +} + // ── Config pattern matching, redirects, rewrites, headers, CSRF validation, // external URL proxy, cookie parsing, and request context are imported from // config-matchers.ts and request-pipeline.ts (see import statements above). @@ -12730,7 +12860,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; } const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); for (const h of extraHeaders) { @@ -12779,11 +12909,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); @@ -15547,6 +15677,32 @@ function __normalizePath(pathname) { return "/" + resolved.join("/"); } +const __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) { + const segments = pathname.split("/"); + const normalized = []; + for (let i = 0; i < segments.length; i++) { + normalized.push(__decodeRouteSegmentSafe(segments[i])); + } + return normalized.join("/"); +} +function __normalizePathnameForRouteMatchStrict(pathname) { + const segments = pathname.split("/"); + const normalized = []; + for (let i = 0; i < segments.length; i++) { + normalized.push(__decodeRouteSegment(segments[i])); + } + return normalized.join("/"); +} + // ── Config pattern matching, redirects, rewrites, headers, CSRF validation, // external URL proxy, cookie parsing, and request context are imported from // config-matchers.ts and request-pipeline.ts (see import statements above). @@ -15672,7 +15828,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; } const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx); for (const h of extraHeaders) { @@ -15721,11 +15877,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); @@ -18650,6 +18806,32 @@ function __normalizePath(pathname) { return "/" + resolved.join("/"); } +var __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) { + var segments = pathname.split("/"); + var normalized = []; + for (var i = 0; i < segments.length; i++) { + normalized.push(__decodeRouteSegmentSafe(segments[i])); + } + return normalized.join("/"); +} +function __normalizePathnameForRouteMatchStrict(pathname) { + var segments = pathname.split("/"); + var normalized = []; + for (var i = 0; i < segments.length; i++) { + normalized.push(__decodeRouteSegment(segments[i])); + } + return normalized.join("/"); +} + function __isSafeRegex(pattern) { var quantifierAtDepth = []; var depth = 0; @@ -18938,7 +19120,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); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index ea91e8b8..5b213f37 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -64,6 +64,16 @@ describe("App Router integration", () => { expect(html).toContain("hello-world"); }); + it("does not collapse encoded slashes onto nested routes in dev", async () => { + const encodedRes = await fetch(`${baseUrl}/headers%2Foverride-from-middleware`); + expect(encodedRes.status).toBe(404); + expect(encodedRes.headers.get("e2e-headers")).not.toBe("middleware"); + + const nestedRes = await fetch(`${baseUrl}/headers/override-from-middleware`); + expect(nestedRes.status).toBe(200); + expect(nestedRes.headers.get("e2e-headers")).toBe("middleware"); + }); + it("handles GET API route handlers", async () => { const res = await fetch(`${baseUrl}/api/hello`); expect(res.status).toBe(200); @@ -1492,6 +1502,16 @@ describe("App Router Production server (startProdServer)", () => { expect(html).toContain(" { + const encodedRes = await fetch(`${baseUrl}/headers%2Foverride-from-middleware`); + expect(encodedRes.status).toBe(404); + expect(encodedRes.headers.get("e2e-headers")).not.toBe("middleware"); + + const nestedRes = await fetch(`${baseUrl}/headers/override-from-middleware`); + expect(nestedRes.status).toBe(200); + expect(nestedRes.headers.get("e2e-headers")).toBe("middleware"); + }); + it("serves dynamic routes", async () => { const res = await fetch(`${baseUrl}/blog/test-post`); expect(res.status).toBe(200); diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 81ec62e2..6b0f5dd6 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -10,6 +10,31 @@ import vinext from "../packages/vinext/src/index.js"; import { PAGES_FIXTURE_DIR, startFixtureServer } from "./helpers.js"; const FIXTURE_DIR = PAGES_FIXTURE_DIR; +const PAGES_APP_COMPONENT = `export default function App({ Component, pageProps }) { + return ; +} +`; + +function writeEncodedSlashPagesFixture(rootDir: string): void { + fs.mkdirSync(path.join(rootDir, "pages", "a"), { recursive: true }); + const nmLink = path.join(rootDir, "node_modules"); + if (!fs.existsSync(nmLink)) { + fs.symlinkSync(path.join(process.cwd(), "node_modules"), nmLink); + } + fs.writeFileSync(path.join(rootDir, "pages", "_app.tsx"), PAGES_APP_COMPONENT); + fs.writeFileSync( + path.join(rootDir, "pages", "a", "b.tsx"), + "export default function Page() { return
nested pages route
; }\n", + ); + fs.writeFileSync( + path.join(rootDir, "middleware.ts"), + `export const config = { matcher: "/a/b" }; +export default function middleware() { + return new Response("nested blocked", { status: 418 }); +} +`, + ); +} describe("Pages Router integration", () => { let server: ViteDevServer; @@ -110,6 +135,28 @@ describe("Pages Router integration", () => { expect(html).toMatch(/Query ID:\s*()?\s*42/); }); + it("does not collapse encoded slashes onto nested routes in dev", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-encoded-dev-")); + writeEncodedSlashPagesFixture(tmpDir); + + let tempServer: ViteDevServer | undefined; + try { + const started = await startFixtureServer(tmpDir); + tempServer = started.server; + + const encodedRes = await fetch(`${started.baseUrl}/a%2Fb`); + expect(encodedRes.status).toBe(404); + expect(await encodedRes.text()).not.toContain("nested blocked"); + + const nestedRes = await fetch(`${started.baseUrl}/a/b`); + expect(nestedRes.status).toBe(418); + expect(await nestedRes.text()).toBe("nested blocked"); + } finally { + await tempServer?.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("returns 404 with custom 404 page for non-existent routes", async () => { const res = await fetch(`${baseUrl}/nonexistent`); expect(res.status).toBe(404); @@ -1851,6 +1898,58 @@ describe("Production server middleware (Pages Router)", () => { expect(res.headers.get("location")).toContain("/about"); }); + it("does not collapse encoded slashes onto nested routes in production", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-encoded-prod-")); + writeEncodedSlashPagesFixture(tmpDir); + + let prodServer: import("node:http").Server | undefined; + try { + await build({ + root: tmpDir, + configFile: false, + plugins: [vinext()], + logLevel: "silent", + build: { + outDir: path.join(tmpDir, "dist", "server"), + ssr: "virtual:vinext-server-entry", + rollupOptions: { output: { entryFileNames: "entry.js" } }, + }, + }); + await build({ + root: tmpDir, + configFile: false, + plugins: [vinext()], + logLevel: "silent", + build: { + outDir: path.join(tmpDir, "dist", "client"), + manifest: true, + ssrManifest: true, + rollupOptions: { input: "virtual:vinext-client-entry" }, + }, + }); + + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + prodServer = await startProdServer({ + port: 0, + host: "127.0.0.1", + outDir: path.join(tmpDir, "dist"), + }); + const addr = prodServer.address() as { port: number }; + const tempProdUrl = `http://127.0.0.1:${addr.port}`; + + const encodedRes = await fetch(`${tempProdUrl}/a%2Fb`); + expect(encodedRes.status).toBe(404); + expect(await encodedRes.text()).not.toContain("nested blocked"); + + const nestedRes = await fetch(`${tempProdUrl}/a/b`); + expect(nestedRes.status).toBe(418); + expect(await nestedRes.text()).toBe("nested blocked"); + } finally { + await new Promise((resolve) => prodServer?.close(() => resolve()) ?? resolve()); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("preserves Set-Cookie headers on middleware redirect", async () => { const res = await fetch(`${prodUrl}/redirect-with-cookies`, { redirect: "manual", diff --git a/tests/routing.test.ts b/tests/routing.test.ts index 792f7608..8d69a898 100644 --- a/tests/routing.test.ts +++ b/tests/routing.test.ts @@ -147,6 +147,29 @@ describe("matchRoute - URL matching", () => { expect(result!.params).toEqual({ id: "42" }); }); + it("preserves encoded slashes within a single static segment", () => { + const encodedRoute = { + pattern: "/a%2Fb", + patternParts: ["a%2Fb"], + filePath: "/tmp/pages/a%2Fb.tsx", + isDynamic: false, + params: [], + } as Route; + const nestedRoute = { + pattern: "/a/b", + patternParts: ["a", "b"], + filePath: "/tmp/pages/a/b.tsx", + isDynamic: false, + params: [], + } as Route; + + expect(matchRoute("/a%2Fb", [encodedRoute, nestedRoute])?.route.pattern).toBe("/a%2Fb"); + expect(matchRoute("/a/b", [encodedRoute, nestedRoute])?.route.pattern).toBe("/a/b"); + // Lowercase %2f should also match: normalizePathnameForRouteMatch decodes + // then re-encodes via encodeURIComponent, which always produces uppercase. + expect(matchRoute("/a%2fb", [encodedRoute, nestedRoute])?.route.pattern).toBe("/a%2Fb"); + }); + it("returns null for unmatched routes", async () => { const routes = await pagesRouter(FIXTURE_DIR); @@ -821,6 +844,26 @@ describe("matchAppRoute - URL matching", () => { expect(result!.params).toEqual({ subdomain: "my-site" }); }); + it("keeps encoded slashes distinct from real nested routes", async () => { + await withTempDir("vinext-app-encoded-slash-route-", async (tmpDir) => { + const appDir = path.join(tmpDir, "app"); + await mkdir(path.join(appDir, "a%2Fb"), { recursive: true }); + await mkdir(path.join(appDir, "a", "b"), { recursive: true }); + await writeFile(path.join(appDir, "a%2Fb", "page.tsx"), EMPTY_PAGE); + await writeFile(path.join(appDir, "a", "b", "page.tsx"), EMPTY_PAGE); + + invalidateAppRouteCache(); + const routes = await appRouter(appDir); + const patterns = routes.map((route) => route.pattern); + + expect(patterns).toContain("/a%2Fb"); + expect(patterns).toContain("/a/b"); + + expect(matchAppRoute("/a%2Fb", routes)?.route.pattern).toBe("/a%2Fb"); + expect(matchAppRoute("/a/b", routes)?.route.pattern).toBe("/a/b"); + }); + }); + it("prioritizes static-prefix routes over bare catch-all routes", async () => { invalidateAppRouteCache(); const routes = await appRouter(APP_FIXTURE_DIR);