fix(web): interpret encoded Next route paths

This commit is contained in:
morluto committed 2026-10-27 07:17:10 +08:00
1 parent 087acf26e8
commit 0dea08fb07
3 files changed
+152 -5

No files matched your search

@@ -421,3 +421,86 @@ it.each([
);
},
);
it("decodes captured Next script URL segments once while retaining source URL identity", () => {
const url =
"https://example.test/base/_next/static/chunks/app/%28shop%29/blog/%5Bid%5D/page-abc.js";
const result = build([
source("captured-page", "", url),
source("app/blog/%5Bid%5D/page.js", ""),
]);
expect(result.routes.map(({ path }) => path)).toEqual([
"/base/blog/:id",
"/blog/%5Bid%5D",
]);
expect(result.sources.find((entry) => entry.url === url)?.url).toBe(url);
expect(result.occurrences).toEqual(
expect.arrayContaining([
expect.objectContaining({
origin: "source-url",
unprefixed_path: "/blog/:id",
inferred_base_path: "/base",
}),
]),
);
});
it.each(["team%2Fadmin", "team%5Cadmin", "%E0%A4%A"])(
"keeps malformed Next URL segment %s unknown alongside usable script facts",
(segment) => {
const url = `https://example.test/_next/static/chunks/app/${segment}/page-abc.js`;
const result = build([source("page", "router.push('/usable');", url)]);
expect(result.routes.map(({ path }) => path)).toEqual(["/usable"]);
expect(result.sources[0]?.url).toBe(url);
expect(result.coverage.candidate_enumeration).toBe("partial");
expect(result.unknowns).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "interpretation",
reason: expect.stringContaining("Next script URL"),
}),
]),
);
},
);
it("preserves literal percent encoding after one script URL decode", () => {
const result = build([
source(
"page",
"",
"https://example.test/_next/static/chunks/app/%255Bid%255D/page-abc.js",
),
]);
expect(result.routes.map(({ path }) => path)).toEqual(["/%5Bid%5D"]);
});
it.each([
"page%3Fnotes.js",
"page%23notes.js",
"page.js%3Fnotes",
"page.js%23notes",
])("keeps encoded Next script filename delimiters literal: %s", (leaf) => {
const url = `https://example.test/_next/static/chunks/app/dashboard/${leaf}`;
const result = build([source("captured-page", "", url)]);
expect(result.routes).toEqual([]);
expect(result.sources[0]?.url).toBe(url);
});
it.each(["?cache=1", "#section", "?cache=1#section"])(
"parses actual Next script URL suffix %s before classifying the filename",
(suffix) => {
const url = `https://example.test/_next/static/chunks/app/dashboard/page-abc.js${suffix}`;
const result = build([source("captured-page", "", url)]);
expect(result.routes.map(({ path }) => path)).toEqual(["/dashboard"]);
expect(result.sources[0]?.url).toBe(url);
},
);
it.each(["page?notes.js", "page#notes.js", "page.js?notes", "page.js#notes"])(
"keeps filesystem Next page filename delimiters literal: %s",
(leaf) => {
const result = build([source(`app/dashboard/${leaf}`, "")]);
expect(result.routes).toEqual([]);
},
);
+30 -5
View File
@@ -62,10 +62,10 @@ export const classifyNextAppRoutePath = (
): NextAppRoutePath | undefined => {
if (raw.includes("\\") || context.kind === "source-reference") return;
let value = raw;
if (
const urlPath =
context.kind === "script-url" ||
(context.kind === "source-literal" && raw.includes("://"))
) {
(context.kind === "source-literal" && raw.includes("://"));
if (urlPath) {
try {
value = new URL(raw).pathname;
} catch {
@@ -118,11 +118,36 @@ export const classifyNextAppRoutePath = (
};
return;
}
if (urlPath) {
// Captured script URLs encode filesystem segments. Decode once here, after
// selecting the Next asset root; source paths retain their literal spelling.
if (/%(?:2f|5c)/iu.test(relative))
return {
kind: "unknown",
reason: "A Next script URL contains an encoded path separator.",
};
try {
relative = decodeURIComponent(relative);
} catch (cause: unknown) {
if (!(cause instanceof URIError)) throw cause;
return {
kind: "unknown",
reason: "A Next script URL contains malformed percent encoding.",
};
}
}
const match =
/^(?:(.*?)\/)?page(?:-([^/?#]+)\.js|\.(?:[cm]?jsx?|tsx?)|)(?:[?#].*)?$/u.exec(
/^(?:(.*?)\/)?page(?:-([^/?#]+)\.js|\.(?:[cm]?jsx?|tsx?)|)([?#].*)?$/u.exec(
relative,
);
if (match === null || (match[2] !== undefined && !chunk)) return;
if (
match === null ||
(match[2] !== undefined && !chunk) ||
// URL parsing already removed URL suffixes. Any decoded delimiter is a
// filename byte, as it is in an inventoried filesystem source path.
(match[3] !== undefined && (urlPath || context.kind === "source-path"))
)
return;
const sourceSegments = (match[1] ?? "").split("/");
// A private folder excludes the whole page, including intercepted ancestors.
// Keep filesystem segment spelling: only Next's explicit %5F escape is