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:
commit
d601b8016e
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)(
|
||||
|
|
|
|||
Loading…
Reference in New Issue