fix(javascript): keep directory-form HTML URL references distinct from files (#4646)

* 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>
This commit is contained in:
Vinícius Andrade
2026-10-22 15:28:05 -03:00
committed by GitHub
parent db8556e724
commit 75575f2006
3 changed files with 56 additions and 24 deletions
@@ -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([
@@ -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", [
@@ -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", () => {