Merge pull request #1287 from morluto/fix/javascript-html-script-url-decoding

fix(javascript): resolve HTML script URLs after URL whitespace and percent decoding
This commit is contained in:
Kaoru0822 2026-10-09 07:13:13 -07:00 committed by GitHub
commit d601b8016e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 156 additions and 10 deletions

View File

@ -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

View File

@ -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);
};
/**

View File

@ -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",
});
},
);
});

View File

@ -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;
}
};

View File

@ -55,6 +55,24 @@ const cases: readonly {
files: { "app.js": "globalThis.owned = true;" },
expected: ["app.js"],
},
{
name: "percent-encoded script letters",
html: '<script src="%61pp.js"></script>',
files: { "app.js": "globalThis.owned = true;" },
expected: ["app.js"],
},
{
name: "script URL surrounded by whitespace",
html: '<script src=" app.js "></script>',
files: { "app.js": "globalThis.owned = true;" },
expected: ["app.js"],
},
{
name: "percent-encoded base directory",
html: '<base href="%61ssets/"><script src="app.js"></script>',
files: { "assets/app.js": "globalThis.owned = true;" },
expected: ["assets/app.js"],
},
];
it.each(cases)(