fix(javascript): resolve HTML script URLs after URL whitespace and percent decoding

An HTML script src or base href with percent-encoded letters or
surrounding whitespace was looked up literally, so an inventoried script
was reported as not-found. Apply HTML attribute and URL parser
whitespace handling, then percent-decode the path as the URL standard
does. Encoded dot and separator bytes are still rejected, and paths that
decode to NUL or non-UTF-8 bytes are rejected explicitly.

Closes #1236

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-09 13:52:55 +00:00
parent 0c6852b392
commit 502528c58d
5 changed files with 156 additions and 10 deletions
@@ -5,7 +5,9 @@ import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptA
import {
admitsCanonicalPathSyntax,
hasScheme,
htmlUrlText,
looksExternal,
percentDecodeUrlPath,
stripQueryAndFragment,
} from "../../domain/artifactPathSyntax.js";
@@ -248,7 +250,7 @@ const hasContainerCandidate = (
const htmlCandidate = (
input: ResolveArtifactPathInput,
): string | ArtifactPathResolution => {
const declared = stripQueryAndFragment(input.declaredPath);
const declared = stripQueryAndFragment(htmlUrlText(input.declaredPath));
if (looksExternal(declared))
return unresolvedOutcome(input, "external", [
"External HTML references are not mapped to local artifact assets.",
@@ -257,14 +259,19 @@ const htmlCandidate = (
const base =
rawBase === undefined || rawBase === null
? rawBase
: stripQueryAndFragment(rawBase);
: stripQueryAndFragment(htmlUrlText(rawBase));
if (base !== undefined && base !== null && looksExternal(base))
return unresolvedOutcome(input, "external", [
"The document base href is external, so its script reference is not a local artifact path.",
]);
if (declared.startsWith("/")) return declared.slice(1);
const declaredPath = percentDecodeUrlPath(declared);
if (declaredPath === null)
return unresolvedOutcome(input, "rejected", [
"The HTML reference path percent-decodes to NUL or to bytes that are not UTF-8.",
]);
if (declaredPath.startsWith("/")) return declaredPath.slice(1);
if (base === undefined || base === null || base === "")
return posix.join(posix.dirname(input.sourcePath), declared);
return posix.join(posix.dirname(input.sourcePath), declaredPath);
// 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.
@@ -272,10 +279,15 @@ const htmlCandidate = (
return unresolvedOutcome(input, "rejected", [
"The document base href uses NUL, backslash, or encoded dot and separator bytes that are not admitted for canonical artifact paths.",
]);
const basePath = base.startsWith("/")
? base.slice(1)
: posix.join(posix.dirname(input.sourcePath), base);
return posix.join(htmlBaseDirectory(base, basePath), declared);
const decodedBase = percentDecodeUrlPath(base);
if (decodedBase === null)
return unresolvedOutcome(input, "rejected", [
"The document base href path percent-decodes to NUL or to bytes that are not UTF-8.",
]);
const basePath = decodedBase.startsWith("/")
? decodedBase.slice(1)
: posix.join(posix.dirname(input.sourcePath), decodedBase);
return posix.join(htmlBaseDirectory(decodedBase, basePath), declaredPath);
};
/**
@@ -109,3 +109,67 @@ describe("HTML base href path syntax", () => {
});
});
});
describe("HTML reference URL text", () => {
const urlPath = (declared: string, base: string) =>
decodeURIComponent(
new URL(declared.trim(), new URL(base.trim(), documentUrl)).pathname,
).replace(/^\/+/, "");
it.each([
["%61pp.js", "/assets/", "assets/app.js"],
[" app.js ", "/assets/", "assets/app.js"],
["\napp.js\t", "/assets/", "assets/app.js"],
["a\npp.js", "/assets/", "assets/app.js"],
["app.js", "%61/", "renderer/a/app.js"],
["app.js", " /%61ssets/ ", "assets/app.js"],
["/%61ssets/app.js", "", "assets/app.js"],
])(
"resolves %j against base %j like the URL parser",
(declared, base, expected) => {
if (base !== "") expect(urlPath(declared, base)).toBe(expected);
expect(resolve(declared, base)).toMatchObject({
declared_path: declared,
resolution_status: "resolved",
resolved_path: expected,
});
},
);
it("keeps a literal percent sign that starts no escape", () => {
expect(resolve("100%.js", "")).toMatchObject({
resolution_status: "not-found",
limitations: [
"The exact HTML resource renderer/100%.js was not found among the selected application files.",
],
});
});
it.each(["%00app.js", "%ff.js"])(
"rejects %j, which decodes to no canonical artifact path",
(declared) => {
expect(resolve(declared, "")).toMatchObject({
resolution_status: "rejected",
resolved_path: null,
});
},
);
it("rejects a base href that decodes to NUL", () => {
expect(resolve("app.js", "/a%00/")).toMatchObject({
resolution_status: "rejected",
resolved_path: null,
});
});
it.each([" https://external.test/app.js", "\t//external.test/app.js"])(
"reports %j as external after removing URL whitespace",
(declared) => {
expect(resolve(declared, "")).toMatchObject({
resolution_status: "external",
});
},
);
it.each(["%2e%2e/app.js", "a%2fapp.js"])(
"still rejects encoded dot and separator bytes in %j",
(declared) => {
expect(resolve(declared, "")).toMatchObject({
resolution_status: "rejected",
});
},
);
});