@@ -14,6 +14,45 @@ export type RouteMatcherParam =
1414 | RouteMatcherWithNextTypedRoutes
1515 | ( ( req : NextRequest ) => boolean ) ;
1616
17+ export class MalformedURLError extends Error {
18+ public readonly statusCode = 400 ;
19+ public readonly cause ?: unknown ;
20+
21+ constructor ( pathname : string , cause ?: unknown ) {
22+ super ( `Malformed encoding in URL path: ${ pathname } ` ) ;
23+ this . name = 'MalformedURLError' ;
24+ this . cause = cause ;
25+ }
26+ }
27+
28+ /**
29+ * String-based check for MalformedURLError that works across package bundles
30+ * where `instanceof` would fail due to duplicate class identities.
31+ */
32+ export function isMalformedURLError ( e : unknown ) : e is MalformedURLError {
33+ return e instanceof Error && e . name === 'MalformedURLError' ;
34+ }
35+
36+ /**
37+ * Normalizes a URL path for safe route matching.
38+ *
39+ * 1. Decodes percent-encoded unreserved characters using decodeURI (not
40+ * decodeURIComponent) so path-reserved delimiters like %2F, %3F, %23
41+ * are preserved — matching how framework routers interpret paths.
42+ * 2. Collapses consecutive slashes (e.g. //api/admin → /api/admin) to
43+ * prevent bypass via extra slashes.
44+ *
45+ * @throws {MalformedURLError } if the path contains invalid percent-encoding
46+ */
47+ const normalizePath = ( pathname : string ) : string => {
48+ try {
49+ pathname = decodeURI ( pathname ) ;
50+ } catch ( e ) {
51+ throw new MalformedURLError ( pathname , e ) ;
52+ }
53+ return pathname . replace ( / \/ \/ + / g, '/' ) ;
54+ } ;
55+
1756/**
1857 * Returns a function that accepts a `Request` object and returns whether the request matches the list of
1958 * predefined routes that can be passed in as the first argument.
@@ -29,7 +68,7 @@ export const createRouteMatcher = (routes: RouteMatcherParam) => {
2968
3069 const routePatterns = [ routes || '' ] . flat ( ) . filter ( Boolean ) ;
3170 const matchers = precomputePathRegex ( routePatterns ) ;
32- return ( req : NextRequest ) => matchers . some ( matcher => matcher . test ( req . nextUrl . pathname ) ) ;
71+ return ( req : NextRequest ) => matchers . some ( matcher => matcher . test ( normalizePath ( req . nextUrl . pathname ) ) ) ;
3372} ;
3473
3574const precomputePathRegex = ( patterns : Array < string | RegExp > ) => {
0 commit comments