Skip to content

Commit 0dc8315

Browse files
authored
fix(nextjs): normalize URL paths in createRouteMatcher [v5] (#8314)
1 parent bf83dd5 commit 0dc8315

4 files changed

Lines changed: 53 additions & 2 deletions

File tree

‎packages/nextjs/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@clerk/nextjs",
3-
"version": "5.7.5",
3+
"version": "5.7.6",
44
"description": "Clerk SDK for NextJS",
55
"keywords": [
66
"clerk",

‎packages/nextjs/src/server/authMiddleware.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,13 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => {
162162

163163
logger.debug('Options debug', { ...options, beforeAuth: !!options.beforeAuth, afterAuth: !!options.afterAuth });
164164

165+
// Reject malformed percent-encoded paths before any matcher runs (GHSA-vqx2-fgx2-5wq9).
166+
try {
167+
decodeURI(nextRequest.nextUrl.pathname);
168+
} catch {
169+
return new NextResponse(null, { status: 400, statusText: 'Bad Request' });
170+
}
171+
165172
if (isIgnoredRoute(nextRequest)) {
166173
logger.debug({ isIgnoredRoute: true });
167174
if (isDevelopmentFromSecretKey(options.secretKey) && !options.ignoredRoutes) {

‎packages/nextjs/src/server/clerkMiddleware.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { PUBLISHABLE_KEY, SECRET_KEY, SIGN_IN_URL, SIGN_UP_URL } from './constan
1414
import { errorThrower } from './errorThrower';
1515
import type { AuthProtect } from './protect';
1616
import { createProtect } from './protect';
17+
import { isMalformedURLError } from './routeMatcher';
1718
import type { NextMiddlewareEvtParam, NextMiddlewareRequestParam, NextMiddlewareReturn } from './types';
1819
import {
1920
assertKey,
@@ -255,6 +256,10 @@ const createMiddlewareProtect = (
255256
// This function handles the known errors thrown by the APIs described above,
256257
// and returns the appropriate response.
257258
const handleControlFlowErrors = (e: any, clerkRequest: ClerkRequest, requestState: RequestState): Response => {
259+
if (isMalformedURLError(e)) {
260+
return new NextResponse(null, { status: 400, statusText: 'Bad Request' });
261+
}
262+
258263
switch (e.message) {
259264
case CONTROL_FLOW_ERROR.FORCE_NOT_FOUND:
260265
// Rewrite to a bogus URL to force not found error

‎packages/nextjs/src/server/routeMatcher.ts‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

3574
const precomputePathRegex = (patterns: Array<string | RegExp>) => {

0 commit comments

Comments
 (0)