From 502528c58df54c7b34eefdb5837f36e58f473405 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:52:55 +0000 Subject: [PATCH] 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 --- docs/javascript-artifact-reconstruction.md | 7 +- .../JavaScriptArtifactPathResolution.ts | 28 +++++--- .../JavaScriptHtmlBaseResolution.test.ts | 64 +++++++++++++++++++ src/domain/artifactPathSyntax.ts | 49 ++++++++++++++ .../javascriptHtmlResourcePaths.test.ts | 18 ++++++ 5 files changed, 156 insertions(+), 10 deletions(-) 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)(