From 0dea08fb072e7a8e9bfa5a72065f688766ad2e0e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:17:01 +0800 Subject: [PATCH] fix(web): interpret encoded Next route paths --- .../javascriptRouteInventory.test.ts | 83 +++++++++++++++++++ src/domain/javascript/javascriptRoutePaths.ts | 35 ++++++-- src/domain/webBundleAnalyzer.test.ts | 39 +++++++++ 3 files changed, 152 insertions(+), 5 deletions(-) diff --git a/src/domain/javascript/javascriptRouteInventory.test.ts b/src/domain/javascript/javascriptRouteInventory.test.ts index a1423210fb..b4fd09c20a 100644 --- a/src/domain/javascript/javascriptRouteInventory.test.ts +++ b/src/domain/javascript/javascriptRouteInventory.test.ts @@ -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([]); + }, +); diff --git a/src/domain/javascript/javascriptRoutePaths.ts b/src/domain/javascript/javascriptRoutePaths.ts index f65cd31f22..ecfe8fd571 100644 --- a/src/domain/javascript/javascriptRoutePaths.ts +++ b/src/domain/javascript/javascriptRoutePaths.ts @@ -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 diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index bd7f9cb6ce..e8bbb3cadb 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -2045,3 +2045,42 @@ it("keeps known capture omissions in compact route summaries and queryable gap f for (const row of gaps.value.items) expect(row).toMatchObject({ source_id: null, provenance: null }); }); + +it("retains captured Next script identity while deriving decoded route patterns", () => { + const url = `${origin}/base/_next/static/chunks/app/%28shop%29/blog/%5Bid%5D/page-abc.js`; + const captured = createWebPageInspection([ + { + source: "router.push('/help');", + scriptKey: `scr_${"2".repeat(64)}`, + url, + origin, + cdpHash: "hash", + }, + ]); + const result = analyzeWebBundleFixture(captured); + expect(result.route_inventory?.routes.map(({ path }) => path)).toEqual([ + "/base/blog/:id", + "/base/help", + ]); + expect(result.route_inventory?.sources).toEqual([ + expect.objectContaining({ + url, + sha256: captured.scripts.items[0]?.source.included + ? captured.scripts.items[0].source.artifact.sha256 + : null, + }), + ]); + expect(result.route_inventory?.occurrences).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + origin: "source-url", + unprefixed_path: "/blog/:id", + inferred_base_path: "/base", + }), + expect.objectContaining({ + origin: "source-syntax", + unprefixed_path: "/help", + }), + ]), + ); +});