diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md
index cd3c824c5..39b003844 100644
--- a/docs/javascript-artifact-reconstruction.md
+++ b/docs/javascript-artifact-reconstruction.md
@@ -294,8 +294,11 @@ do not select a later entry. Rejected references retain the package metadata
path, original target, failed constraint, and importing source location.
HTML script references resolve to exact inventoried files after applying the
-document base and query/fragment rules. CommonJS module lookups retain extension
-and directory resolution.
+document base and query/fragment rules. As in a browser, the script URL and base
+href are read without surrounding whitespace or embedded tabs and newlines, and
+their percent-encoded path bytes are decoded; encoded dot and separator bytes
+are still rejected. CommonJS module lookups retain extension and directory
+resolution.
Unresolved HTML references retain their declaration, source range, and resolution
reason in the renderer observations.
HTML script source ranges follow the HTML parser across LF, CRLF, and bare CR
diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts
index 900f1ba49..c2485a11d 100644
--- a/src/application/javascript/JavaScriptArtifactPathResolution.ts
+++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts
@@ -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);
};
/**
diff --git a/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts b/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts
index e225c0af1..49a5f7ad2 100644
--- a/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts
+++ b/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts
@@ -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",
+ });
+ },
+ );
+});
diff --git a/src/domain/artifactPathSyntax.ts b/src/domain/artifactPathSyntax.ts
index 382856a4c..5641da1b7 100644
--- a/src/domain/artifactPathSyntax.ts
+++ b/src/domain/artifactPathSyntax.ts
@@ -32,3 +32,52 @@ export const admitsCanonicalPathSyntax = (value: string): boolean =>
!value.includes("\0") &&
!value.includes("\\") &&
!/%(?:2e|2f|5c)/iu.test(value);
+
+/** C0 controls and space, which the URL parser strips around its input. */
+const isUrlTrimmed = (code: number): boolean => code <= 0x20;
+
+/**
+ * The URL text a browser parses from an HTML URL attribute: HTML strips
+ * surrounding ASCII whitespace, and the URL parser strips surrounding C0
+ * controls and spaces and removes every tab and newline.
+ */
+export const htmlUrlText = (value: string): string => {
+ let start = 0;
+ let end = value.length;
+ while (start < end && isUrlTrimmed(value.charCodeAt(start))) start += 1;
+ while (end > start && isUrlTrimmed(value.charCodeAt(end - 1))) end -= 1;
+ return value.slice(start, end).replace(/[\t\n\r]/gu, "");
+};
+
+const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
+const UTF8_ENCODER = new TextEncoder();
+
+/**
+ * Percent-decode a URL path as the URL standard does: valid `%XX` sequences
+ * become bytes and any other `%` stays literal. Returns null when the bytes
+ * are not UTF-8 or include NUL, because no canonical artifact path can match
+ * them. Callers refuse encoded dot and separator bytes before decoding.
+ */
+export const percentDecodeUrlPath = (value: string): string | null => {
+ if (!/%[0-9a-f]{2}/iu.test(value)) return value;
+ const bytes: number[] = [];
+ for (let index = 0; index < value.length;) {
+ const escape = value.slice(index, index + 3);
+ if (/^%[0-9a-f]{2}$/iu.test(escape)) {
+ bytes.push(Number.parseInt(escape.slice(1), 16));
+ index += 3;
+ continue;
+ }
+ const character = String.fromCodePoint(value.codePointAt(index) ?? 0);
+ bytes.push(...UTF8_ENCODER.encode(character));
+ index += character.length;
+ }
+ try {
+ const decoded = UTF8.decode(new Uint8Array(bytes));
+ return decoded.includes("\0") ? null : decoded;
+ } catch (cause: unknown) {
+ // Invalid UTF-8 cannot name an inventoried path.
+ void cause;
+ return null;
+ }
+};
diff --git a/tests/boundary/filesystem/javascriptHtmlResourcePaths.test.ts b/tests/boundary/filesystem/javascriptHtmlResourcePaths.test.ts
index 1edd18a86..979951b3c 100644
--- a/tests/boundary/filesystem/javascriptHtmlResourcePaths.test.ts
+++ b/tests/boundary/filesystem/javascriptHtmlResourcePaths.test.ts
@@ -55,6 +55,24 @@ const cases: readonly {
files: { "app.js": "globalThis.owned = true;" },
expected: ["app.js"],
},
+ {
+ name: "percent-encoded script letters",
+ html: '',
+ files: { "app.js": "globalThis.owned = true;" },
+ expected: ["app.js"],
+ },
+ {
+ name: "script URL surrounded by whitespace",
+ html: '',
+ files: { "app.js": "globalThis.owned = true;" },
+ expected: ["app.js"],
+ },
+ {
+ name: "percent-encoded base directory",
+ html: '',
+ files: { "assets/app.js": "globalThis.owned = true;" },
+ expected: ["assets/app.js"],
+ },
];
it.each(cases)(