From 75575f2006ef2f68aceb8626bc3293ddc8529df0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Andrade?= <95101505+viniciusdsandrade@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:28:05 -0300 Subject: [PATCH] fix(javascript): keep directory-form HTML URL references distinct from files (#4646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(javascript): keep directory-form HTML URL references distinct from files Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com> * test(javascript): cover directory-form HTML URL references Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com> --------- Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com> --- .../JavaScriptArtifactPathResolution.test.ts | 26 +++++++------ .../JavaScriptArtifactPathResolution.ts | 37 +++++++++++++------ .../JavaScriptHtmlBaseResolution.test.ts | 17 +++++++++ 3 files changed, 56 insertions(+), 24 deletions(-) diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index e5ece75e21..31489dc28e 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -3184,18 +3184,20 @@ describe("url-reference directory and empty-segment semantics", () => { "/dist/worker.js/.", "./worker.js/.?cache=1#entry", ])("keeps directory-form URLs distinct from files: %s", (declaredPath) => { - expect( - resolve({ - declaredPath, - sourcePath: "dist/app.mjs", - context: "url-reference", - files: fileMap([file("dist/worker.js", "root")]), - }), - ).toMatchObject({ - declared_path: declaredPath, - resolution_status: "not-found", - resolved_path: null, - }); + for (const context of ["url-reference", "html-reference"] as const) + expect( + resolve({ + declaredPath, + sourcePath: "dist/app.mjs", + context, + files: fileMap([file("dist/worker.js", "root")]), + }), + context, + ).toMatchObject({ + declared_path: declaredPath, + resolution_status: "not-found", + resolved_path: null, + }); }); it.each([ diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index fc0a390920..5f02256700 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -1315,11 +1315,19 @@ const htmlCandidate = ( "The HTML reference path percent-decodes to NUL or to bytes that are not UTF-8.", ]); if (declaredPath.startsWith("/")) - return urlRelativeCandidate("", declaredPath); + return exactUrlPathCandidate( + declaredPath, + urlRelativeCandidate("", declaredPath), + "HTML", + ); if (base === undefined || base === null || base === "") return declaredPath === "" ? input.sourcePath - : urlRelativeCandidate(posix.dirname(input.sourcePath), declaredPath); + : exactUrlPathCandidate( + declaredPath, + urlRelativeCandidate(posix.dirname(input.sourcePath), declaredPath), + "HTML", + ); // A local base href is a second untrusted path input; apply the same // admission rules a declared path gets so it cannot smuggle traversal or // separator syntax past canonicalization. @@ -1338,23 +1346,28 @@ const htmlCandidate = ( urlDirectoryState(posix.dirname(input.sourcePath)), decodedBase, ); - if (declaredPath === "") { - const basePath = urlPathCandidate(baseState); - return typeof basePath === "string" - ? exactUrlPathCandidate(decodedBase, basePath, "HTML") - : basePath; - } - return urlPathCandidate( - applyUrlReference(urlBaseDirectory(baseState), declaredPath), + if (declaredPath === "") + return exactUrlPathCandidate( + decodedBase, + urlPathCandidate(baseState), + "HTML", + ); + return exactUrlPathCandidate( + declaredPath, + urlPathCandidate( + applyUrlReference(urlBaseDirectory(baseState), declaredPath), + ), + "HTML", ); }; const exactUrlPathCandidate = ( declared: string, - candidate: string, + candidate: string | CandidateResolution, kind: "HTML" | "URL" | "module URL", ): string | CandidateResolution => { - if (!urlPathIsDirectory(declared)) return candidate; + if (typeof candidate !== "string" || !urlPathIsDirectory(declared)) + return candidate; const confined = confineCandidate(candidate); if (typeof confined !== "string") return confined; return unresolvedCandidate("not-found", [ diff --git a/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts b/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts index 1a4bbd8503..245a63d2bd 100644 --- a/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts +++ b/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts @@ -105,6 +105,23 @@ describe("HTML base href dot segments", () => { }); }, ); + it.each([ + ["app.js/", "/assets/", "assets/app.js"], + ["app.js/.", "/assets/", "assets/app.js"], + ["?cache=1", "/assets/app.js/", "assets/app.js"], + ])( + "keeps directory-form HTML reference %s with base %s distinct from %s", + (declared, base, directory) => { + expect(resolve(declared, base)).toMatchObject({ + declared_path: declared, + resolution_status: "not-found", + resolved_path: null, + limitations: [ + `The HTML reference resolves to directory ${directory}, not an exact selected application file.`, + ], + }); + }, + ); }); describe("HTML base href empty segments", () => { it("lets the script reference remove an empty segment from the base", () => {