@@ -6,10 +6,49 @@ export type WithPathPatternWildcard<T = string> = `${T & string}(.*)`;
66export type PathPattern = Autocomplete < WithPathPatternWildcard > ;
77export type PathMatcherParam = Array < RegExp | PathPattern > | RegExp | PathPattern ;
88
9+ export class MalformedURLError extends Error {
10+ public readonly statusCode = 400 ;
11+ public readonly cause ?: unknown ;
12+
13+ constructor ( pathname : string , cause ?: unknown ) {
14+ super ( `Malformed encoding in URL path: ${ pathname } ` ) ;
15+ this . name = 'MalformedURLError' ;
16+ this . cause = cause ;
17+ }
18+ }
19+
20+ /**
21+ * String-based check for MalformedURLError that works across package bundles
22+ * where `instanceof` would fail due to duplicate class identities.
23+ */
24+ export function isMalformedURLError ( e : unknown ) : e is MalformedURLError {
25+ return e instanceof Error && e . name === 'MalformedURLError' ;
26+ }
27+
928const precomputePathRegex = ( patterns : Array < string | RegExp > ) => {
1029 return patterns . map ( pattern => ( pattern instanceof RegExp ? pattern : pathToRegexp ( pattern ) ) ) ;
1130} ;
1231
32+ /**
33+ * Normalizes a URL path for safe route matching.
34+ *
35+ * 1. Decodes percent-encoded unreserved characters using decodeURI (not
36+ * decodeURIComponent) so path-reserved delimiters like %2F, %3F, %23
37+ * are preserved — matching how framework routers interpret paths.
38+ * 2. Collapses consecutive slashes (e.g. //api/admin → /api/admin) to
39+ * prevent bypass via extra slashes.
40+ *
41+ * @throws {MalformedURLError } if the path contains invalid percent-encoding
42+ */
43+ export const normalizePath = ( pathname : string ) : string => {
44+ try {
45+ pathname = decodeURI ( pathname ) ;
46+ } catch ( e ) {
47+ throw new MalformedURLError ( pathname , e ) ;
48+ }
49+ return pathname . replace ( / \/ \/ + / g, '/' ) ;
50+ } ;
51+
1352/**
1453 * Creates a function that matches paths against a set of patterns.
1554 *
@@ -19,5 +58,5 @@ const precomputePathRegex = (patterns: Array<string | RegExp>) => {
1958export const createPathMatcher = ( patterns : PathMatcherParam ) => {
2059 const routePatterns = [ patterns || '' ] . flat ( ) . filter ( Boolean ) ;
2160 const matchers = precomputePathRegex ( routePatterns ) ;
22- return ( pathname : string ) => matchers . some ( matcher => matcher . test ( pathname ) ) ;
61+ return ( pathname : string ) => matchers . some ( matcher => matcher . test ( normalizePath ( pathname ) ) ) ;
2362} ;
0 commit comments