fix(javascript): respect HTML base URL components (#589)

* fix(javascript): respect HTML base URL components

Signed-off-by: Rudy Celekli <[email protected]>

* fix(javascript): resolve HTML base href dot segments and gate base syntax

A relative HTML reference resolves against the base URL's directory, and that
directory drops a trailing "." or ".." segment rather than stepping through
it. Only a trailing "/" was treated as a directory, so <base href=".">
resolved against the artifact root instead of the document directory, and
<base href="sub/."> resolved against the parent of "sub".

The base href is also a second untrusted path input, but rejectDeclaration
only applied its admission rules to the declared path, so an encoded-traversal
or backslash base href relied on files-map confinement to stay safe. Both
inputs now share one admitsCanonicalPathSyntax predicate, and a local base
href is rejected with a reason naming the base href rather than a generic
declared-path message. External base hrefs are still reported as external,
so an encoded path on an unreachable origin keeps the more accurate reason.

7 of the new cases fail without this change.

* style: apply Prettier to the HTML base resolution changes

---------

Signed-off-by: Rudy Celekli <[email protected]>
Co-authored-by: morluto <[email protected]>
This commit is contained in:
Rudy Mizrahi Celekliandmorluto authored and GitHub committed 2026-10-06 01:02:56 +08:00
1 parent 82957c98bf
commit ffbde92f9a
2 files changed
+155 -9

No files matched your search

@@ -108,13 +108,19 @@ const rejectDeclaration = (
return unresolvedOutcome(input, "rejected", [
"NUL and backslash path syntax are not admitted for canonical artifact paths.",
]);
if (/%(?:2e|2f|5c)/iu.test(declared))
if (!admitsCanonicalPathSyntax(declared))
return unresolvedOutcome(input, "rejected", [
"Encoded dot or separator bytes are rejected before artifact path resolution.",
]);
return null;
};
/** Path syntax admitted for a canonical artifact path. */
const admitsCanonicalPathSyntax = (value: string): boolean =>
!value.includes("\0") &&
!value.includes("\\") &&
!/%(?:2e|2f|5c)/iu.test(value);
const contextualCandidate = (
input: ResolveArtifactPathInput,
): string | ArtifactPathResolution => {
@@ -206,25 +212,54 @@ const htmlCandidate = (
input: ResolveArtifactPathInput,
): string | ArtifactPathResolution => {
const declared = stripQueryAndFragment(input.declaredPath);
if (hasScheme(declared))
if (hasScheme(declared) || declared.startsWith("//"))
return unresolvedOutcome(input, "external", [
"External HTML references are not mapped to local artifact assets.",
]);
if (declared.startsWith("/")) return declared.slice(1);
const base = input.htmlBaseHref;
if (base === undefined || base === null || base === "")
return posix.join(posix.dirname(input.sourcePath), declared);
if (hasScheme(base))
const rawBase = input.htmlBaseHref;
const base =
rawBase === undefined || rawBase === null
? rawBase
: stripQueryAndFragment(rawBase);
if (
base !== undefined &&
base !== null &&
(hasScheme(base) || base.startsWith("//"))
)
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);
if (base === undefined || base === null || base === "")
return posix.join(posix.dirname(input.sourcePath), declared);
// 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.
if (!admitsCanonicalPathSyntax(base))
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);
const baseDirectory = base.endsWith("/") ? basePath : posix.dirname(basePath);
return posix.join(baseDirectory, declared);
return posix.join(htmlBaseDirectory(base, basePath), declared);
};
/**
* The directory a relative HTML reference resolves against. A base path
* ending in "/" or in a "." or ".." segment is already a directory, because a
* relative reference resolves against the base URL's directory and those
* segments are dropped rather than stepped through.
*/
const htmlBaseDirectory = (base: string, basePath: string): string =>
base.endsWith("/") ||
base.endsWith("/.") ||
base.endsWith("/..") ||
base === "." ||
base === ".."
? basePath
: posix.dirname(basePath);
const confineCandidate = (
input: ResolveArtifactPathInput,
candidate: string,