@@ -7,6 +7,45 @@ type RouteMatcherRoutes = Autocomplete<WithPathPatternWildcard>;
77
88export type RouteMatcherParam = Array < RegExp | RouteMatcherRoutes > | RegExp | RouteMatcherRoutes ;
99
10+ export class MalformedURLError extends Error {
11+ public readonly statusCode = 400 ;
12+ public readonly cause ?: unknown ;
13+
14+ constructor ( pathname : string , cause ?: unknown ) {
15+ super ( `Malformed encoding in URL path: ${ pathname } ` ) ;
16+ this . name = 'MalformedURLError' ;
17+ this . cause = cause ;
18+ }
19+ }
20+
21+ /**
22+ * String-based check for MalformedURLError that works across package bundles
23+ * where `instanceof` would fail due to duplicate class identities.
24+ */
25+ export function isMalformedURLError ( e : unknown ) : e is MalformedURLError {
26+ return e instanceof Error && e . name === 'MalformedURLError' ;
27+ }
28+
29+ /**
30+ * Normalizes a URL path for safe route matching.
31+ *
32+ * 1. Decodes percent-encoded unreserved characters using decodeURI (not
33+ * decodeURIComponent) so path-reserved delimiters like %2F, %3F, %23
34+ * are preserved — matching how framework routers interpret paths.
35+ * 2. Collapses consecutive slashes (e.g. //api/admin → /api/admin) to
36+ * prevent bypass via extra slashes.
37+ *
38+ * @throws {MalformedURLError } if the path contains invalid percent-encoding
39+ */
40+ const normalizePath = ( pathname : string ) : string => {
41+ try {
42+ pathname = decodeURI ( pathname ) ;
43+ } catch ( e ) {
44+ throw new MalformedURLError ( pathname , e ) ;
45+ }
46+ return pathname . replace ( / \/ \/ + / g, '/' ) ;
47+ } ;
48+
1049// TODO-SHARED: This can be moved to @clerk/shared as an identical implementation exists in @clerk/nextjs
1150/**
1251 * Returns a function that accepts a `Request` object and returns whether the request matches the list of
@@ -19,7 +58,7 @@ export type RouteMatcherParam = Array<RegExp | RouteMatcherRoutes> | RegExp | Ro
1958export const createRouteMatcher = ( routes : RouteMatcherParam ) => {
2059 const routePatterns = [ routes || '' ] . flat ( ) . filter ( Boolean ) ;
2160 const matchers = precomputePathRegex ( routePatterns ) ;
22- return ( req : Request ) => matchers . some ( matcher => matcher . test ( new URL ( req . url ) . pathname ) ) ;
61+ return ( req : Request ) => matchers . some ( matcher => matcher . test ( normalizePath ( new URL ( req . url ) . pathname ) ) ) ;
2362} ;
2463
2564const precomputePathRegex = ( patterns : Array < string | RegExp > ) => {
0 commit comments