Skip to content

Commit 255be4e

Browse files
authored
fix(middleware): align encoded path matching (#2802)
* fix(middleware): align encoded path matching * test(pages): separate matcher and route identity * fix(middleware): preserve delimiter matcher parity * fix(middleware): preserve trailing source delimiters * test(middleware): align trailing source expectations * fix(middleware): align trailing slash matcher normalization
1 parent dfc979c commit 255be4e

11 files changed

Lines changed: 650 additions & 81 deletions

File tree

packages/vinext/src/server/middleware-matcher.ts

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
type RequestContext,
55
} from "../config/config-matchers.js";
66
import type { NextI18nConfig } from "../config/next-config.js";
7-
import { removeTrailingSlash } from "../utils/base-path.js";
87
import {
98
compileMiddlewareMatcherPattern,
109
isValidMiddlewareMatcherObjectConfig,
@@ -13,6 +12,10 @@ import {
1312

1413
export type MatcherConfig = string | Array<string | MiddlewareMatcherObject>;
1514

15+
export type MiddlewareLocaleMatchContext =
16+
| { kind: "defaulted"; defaultLocale: string }
17+
| { kind: "internal" | "literal" };
18+
1619
const EMPTY_MIDDLEWARE_REQUEST_CONTEXT: RequestContext = {
1720
headers: new Headers(),
1821
cookies: {},
@@ -30,13 +33,14 @@ export function matchesMiddleware(
3033
matcher: MatcherConfig | undefined,
3134
request?: Request,
3235
i18nConfig?: NextI18nConfig | null,
36+
localeContext?: MiddlewareLocaleMatchContext,
3337
): boolean {
3438
if (!matcher) {
3539
return true;
3640
}
3741

3842
if (typeof matcher === "string") {
39-
return matchMatcherPattern(pathname, matcher, i18nConfig);
43+
return matchMatcherPattern(pathname, matcher, i18nConfig, localeContext);
4044
}
4145
if (!Array.isArray(matcher)) {
4246
return true;
@@ -48,7 +52,7 @@ export function matchesMiddleware(
4852

4953
for (const m of matcher) {
5054
if (typeof m === "string") {
51-
if (matchMatcherPattern(pathname, m, i18nConfig)) {
55+
if (matchMatcherPattern(pathname, m, i18nConfig, localeContext)) {
5256
return true;
5357
}
5458
continue;
@@ -57,7 +61,7 @@ export function matchesMiddleware(
5761
if (!isValidMiddlewareMatcherObjectConfig(m)) {
5862
return true;
5963
}
60-
if (!matchObjectMatcher(pathname, m, i18nConfig)) {
64+
if (!matchObjectMatcher(pathname, m, i18nConfig, localeContext)) {
6165
continue;
6266
}
6367

@@ -75,9 +79,18 @@ function matchMatcherPattern(
7579
pathname: string,
7680
pattern: string,
7781
i18nConfig?: NextI18nConfig | null,
82+
localeContext?: MiddlewareLocaleMatchContext,
7883
): boolean {
7984
if (!i18nConfig) return matchPattern(pathname, pattern);
8085

86+
if (localeContext) {
87+
if (localeContext.kind === "internal") return false;
88+
return matchPattern(
89+
localeContext.kind === "literal" ? stripFirstPathSegment(pathname) : pathname,
90+
pattern,
91+
);
92+
}
93+
8194
const localeStrippedPathname = stripLocalePrefix(pathname, i18nConfig);
8295
return matchPattern(localeStrippedPathname ?? pathname, pattern);
8396
}
@@ -86,35 +99,50 @@ function matchObjectMatcher(
8699
pathname: string,
87100
matcher: MiddlewareMatcherObject,
88101
i18nConfig?: NextI18nConfig | null,
102+
localeContext?: MiddlewareLocaleMatchContext,
89103
): boolean {
90-
return matcher.locale === false
91-
? matchPattern(pathname, matcher.source)
92-
: matchMatcherPattern(pathname, matcher.source, i18nConfig);
104+
if (matcher.locale !== false) {
105+
return matchMatcherPattern(pathname, matcher.source, i18nConfig, localeContext);
106+
}
107+
108+
const matchPathname =
109+
localeContext?.kind === "defaulted"
110+
? `/${localeContext.defaultLocale}${pathname === "/" ? "" : pathname}`
111+
: pathname;
112+
return matchPattern(matchPathname, matcher.source);
93113
}
94114

95115
function stripLocalePrefix(pathname: string, i18nConfig: NextI18nConfig): string | null {
96116
if (pathname === "/") return null;
97117

98118
const segments = pathname.split("/");
99119
const firstSegment = segments[1];
100-
if (!firstSegment || !i18nConfig.locales.includes(firstSegment)) {
120+
const lowerFirstSegment = firstSegment?.toLowerCase();
121+
if (
122+
!lowerFirstSegment ||
123+
!i18nConfig.locales.some((locale) => locale.toLowerCase() === lowerFirstSegment)
124+
) {
101125
return null;
102126
}
103127

104-
return "/" + segments.slice(2).join("/");
128+
return stripFirstPathSegment(pathname);
129+
}
130+
131+
function stripFirstPathSegment(pathname: string): string {
132+
return "/" + pathname.split("/").slice(2).join("/");
105133
}
106134

107135
export function matchPattern(pathname: string, pattern: string): boolean {
108-
const hasPatternSyntax = /[\\():*+?]/.test(pattern);
109-
const normalizedPattern = hasPatternSyntax ? pattern : removeTrailingSlash(pattern);
110-
let cached = _mwPatternCache.get(normalizedPattern);
136+
if (pattern === "/" && (pathname === "//" || pathname === "/?" || pathname === "/#")) {
137+
return false;
138+
}
139+
let cached = _mwPatternCache.get(pattern);
111140
if (cached === undefined) {
112-
cached = compileMatcherPattern(normalizedPattern);
113-
_mwPatternCache.set(normalizedPattern, cached);
141+
cached = compileMatcherPattern(pattern);
142+
_mwPatternCache.set(pattern, cached);
114143
}
115144
if (cached === UNSAFE_MATCHER_PATTERN) return true;
116-
if (cached.test(pathname)) return true;
117-
return pathname.endsWith("/") && cached.test(removeTrailingSlash(pathname));
145+
return cached.test(pathname);
118146
}
119147

120148
function compileMatcherPattern(pattern: string): CompiledMatcherPattern {

packages/vinext/src/server/middleware-runtime.ts

Lines changed: 105 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import "./server-globals.js";
22
import type { NextI18nConfig } from "../config/next-config.js";
3+
import { normalizeHost } from "../config/request-context.js";
34
import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js";
45
import path from "pathslash";
56
import {
@@ -14,7 +15,11 @@ import {
1415
MIDDLEWARE_NEXT_HEADER,
1516
MIDDLEWARE_REWRITE_HEADER,
1617
} from "./headers.js";
17-
import { matchesMiddleware, type MatcherConfig } from "./middleware-matcher.js";
18+
import {
19+
matchesMiddleware,
20+
type MatcherConfig,
21+
type MiddlewareLocaleMatchContext,
22+
} from "./middleware-matcher.js";
1823
import { shouldKeepMiddlewareHeader } from "../utils/middleware-request-headers.js";
1924
import { processMiddlewareHeaders } from "./request-pipeline.js";
2025
import { badRequestResponse, internalServerErrorResponse } from "./http-error-responses.js";
@@ -25,6 +30,7 @@ import {
2530
removeTrailingSlash,
2631
stripBasePath,
2732
} from "../utils/base-path.js";
33+
import { normalizeDefaultLocalePathname } from "./pages-i18n.js";
2834

2935
export type MiddlewareModule = Record<string, unknown>;
3036

@@ -320,15 +326,16 @@ export async function executeMiddleware(
320326
if (normalizedPathname instanceof Response) {
321327
return { continue: false, response: normalizedPathname };
322328
}
329+
const requestUrl = new URL(options.request.url);
330+
const requestPathname = requestUrl.pathname;
323331

324332
// Default: derive in-basePath state from the request URL. The Pages
325333
// prod/deploy adapters pass the original URL — prefixed for in-basePath
326334
// requests, bare for out-of-basePath requests — so the URL itself is the
327335
// source of truth. Callers that pass pre-stripped URLs (dev server, App
328336
// Router) override this with an explicit `hadBasePath: true`.
329337
const hadBasePath =
330-
options.hadBasePath ??
331-
(!options.basePath || hasBasePath(new URL(options.request.url).pathname, options.basePath));
338+
options.hadBasePath ?? (!options.basePath || hasBasePath(requestPathname, options.basePath));
332339

333340
// Matcher patterns use basePath-stripped paths (e.g. /about, not /root/about),
334341
// matching Next.js behavior where the matcher is evaluated against the path
@@ -341,15 +348,104 @@ export async function executeMiddleware(
341348
? stripBasePath(normalizedPathname, options.basePath)
342349
: normalizedPathname;
343350
const matchPathname = basePathStrippedPathname;
351+
// Next.js tests the normalized encoded pathname first, then retries after
352+
// decoding the full path once. Testing only a segment-decoded form lets
353+
// percent-encoded line terminators turn into characters that `.` cannot
354+
// match, while preserving encoded delimiters misses matchers that Next.js
355+
// evaluates against their decoded path structure.
356+
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/next-server.ts
357+
// Next.js removes the request pathname's terminal slash before evaluating
358+
// the compiled middleware matcher. The matcher compiler still appends its
359+
// own optional terminal delimiter, so a source without a slash matches both
360+
// request spellings while a source that includes a slash remains distinct.
361+
// https://github.com/vercel/next.js/blob/v16.2.6/packages/next/src/server/next-server.ts
362+
const encodedRequestPathname = removeTrailingSlash(normalizePath(requestPathname));
363+
const matcher = middlewareMatcher(options.module);
364+
const prepareMatcherPathname = (candidate: string): string | null => {
365+
if (!options.basePath) return candidate;
366+
if (hasBasePath(candidate, options.basePath)) {
367+
return stripBasePath(candidate, options.basePath);
368+
}
369+
if (
370+
candidate.length === options.basePath.length + 1 &&
371+
candidate.startsWith(options.basePath) &&
372+
(candidate.endsWith("?") || candidate.endsWith("#"))
373+
) {
374+
return "/";
375+
}
376+
// App Router and Pages dev may pass a URL that the adapter already
377+
// stripped after recording that it crossed the configured basePath.
378+
if (options.hadBasePath === true) return candidate;
379+
// Next.js prefixes configured matchers with basePath at build time. Keep
380+
// default middleware eligible on absolute paths, but custom matchers must
381+
// not apply outside the basePath.
382+
return matcher === undefined ? candidate : null;
383+
};
384+
const encodedMatchPathname = prepareMatcherPathname(encodedRequestPathname);
385+
let decodedMatchPathname = encodedMatchPathname;
386+
try {
387+
if (encodedMatchPathname !== null) {
388+
decodedMatchPathname = decodeURIComponent(encodedMatchPathname);
389+
} else if (!options.i18nConfig) {
390+
// Without i18n, Next.js can discover an encoded basePath on the decoded
391+
// matcher attempt. With i18n, default-locale insertion has already made
392+
// that path ineligible for the compiled basePath-prefixed matcher.
393+
decodedMatchPathname = prepareMatcherPathname(decodeURIComponent(encodedRequestPathname));
394+
}
395+
} catch {
396+
// Match Next.js: malformed encoding is non-fatal for matcher eligibility.
397+
}
344398

345-
if (
346-
!matchesMiddleware(
347-
matchPathname,
348-
middlewareMatcher(options.module),
399+
let localeContext: MiddlewareLocaleMatchContext | undefined;
400+
if (options.i18nConfig && encodedMatchPathname !== null) {
401+
const hostname = normalizeHost(options.request.headers.get("host"), requestUrl.hostname);
402+
const firstSegment = encodedMatchPathname.split("/", 3)[1];
403+
const hasLiteralLocale =
404+
firstSegment !== undefined &&
405+
options.i18nConfig.locales.some(
406+
(locale) => locale.toLowerCase() === firstSegment.toLowerCase(),
407+
);
408+
if (hasLiteralLocale) {
409+
localeContext = { kind: "literal" };
410+
} else {
411+
const localeDefaultedPathname = normalizeDefaultLocalePathname(
412+
encodedMatchPathname,
413+
options.i18nConfig,
414+
{ hostname },
415+
);
416+
localeContext =
417+
localeDefaultedPathname === encodedMatchPathname
418+
? { kind: "internal" }
419+
: {
420+
defaultLocale: normalizeDefaultLocalePathname("/", options.i18nConfig, {
421+
hostname,
422+
}).slice(1),
423+
kind: "defaulted",
424+
};
425+
}
426+
}
427+
const encodedMatches =
428+
encodedMatchPathname !== null &&
429+
matchesMiddleware(
430+
encodedMatchPathname,
431+
matcher,
349432
options.request,
350433
options.i18nConfig,
351-
)
352-
) {
434+
localeContext,
435+
);
436+
const decodedMatches =
437+
!encodedMatches &&
438+
decodedMatchPathname !== null &&
439+
decodedMatchPathname !== encodedMatchPathname &&
440+
matchesMiddleware(
441+
decodedMatchPathname,
442+
matcher,
443+
options.request,
444+
options.i18nConfig,
445+
localeContext,
446+
);
447+
448+
if (!encodedMatches && !decodedMatches) {
353449
return { continue: true };
354450
}
355451

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function LocalizedAdminPage() {
2+
return <main>protected localized admin page</main>;
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function GET() {
2+
return new Response("protected invoice route handler");
3+
}

tests/fixtures/middleware-matcher-auth/middleware.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,24 @@ export const config = {
3333
],
3434
missing: [{ type: "query", key: "blocked", value: "1" }],
3535
},
36+
{
37+
// Matches the negative-lookahead shape recommended in the Next.js docs.
38+
// The header keeps this broad matcher isolated to the encoded-path auth
39+
// regression without changing the fixture's existing public routes.
40+
source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
41+
has: [{ type: "header", key: "x-encoded-path-auth", value: "1" }],
42+
},
43+
{
44+
source: "/orders/:id(\\d+)",
45+
has: [{ type: "header", key: "x-encoded-delimiter-auth", value: "1" }],
46+
},
47+
{
48+
source: "/xx/admin/dash/board",
49+
has: [{ type: "header", key: "x-encoded-delimiter-auth", value: "1" }],
50+
},
51+
{
52+
source: "/billing/invoices/current/q3",
53+
has: [{ type: "header", key: "x-encoded-delimiter-auth", value: "1" }],
54+
},
3655
],
3756
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function OrderPage() {
2+
return <main>protected order page</main>;
3+
}

0 commit comments

Comments
 (0)