fix(javascript): keep empty URL path segments significant (#3792)
* fix(javascript): keep empty URL path segments significant ESM imports and HTML script references were joined with POSIX path rules, which collapse '//' before applying '..'. URL resolution removes the empty segment instead, so './assets//../app.js' names assets/app.js, not app.js, and the graph linked the importer and renderer to the wrong file. URL references refused every '//' even when dot segments cancel it. Resolve ESM, HTML (including document base) and URL references through one URL dot-segment helper. A remaining empty segment stays unavailable, references above the artifact root are still rejected, and CommonJS keeps filesystem path semantics. Fixes #3307 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(javascript): resolve HTML base and reference as one URL path The base href was turned into a canonical path before the script reference was applied. A base empty segment that the reference's '..' would remove was rejected early, and an above-root '..' from the base became a removable segment that the reference's '..' could cancel. Keep one URL path state through base and reference resolution, with above-root escapes held apart from removable segments, and reject empty segments only in the final path. Remove the superseded htmlBaseDirectory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3ded799bc7
commit
90c6922905
4 files changed
+176
-29
No files matched your search
@@ -551,22 +551,24 @@ const contextualCandidate = (
|
||||
return unresolvedCandidate("rejected", [
|
||||
"The URL reference path percent-decodes to NUL or to bytes that are not UTF-8.",
|
||||
]);
|
||||
// Empty URL segments are significant; canonical filesystem paths cannot
|
||||
// preserve them, including their interaction with following dot segments.
|
||||
if (decoded.includes("//"))
|
||||
return unresolvedCandidate("unavailable", [
|
||||
"Repeated URL path separators cannot be mapped to an exact canonical artifact file.",
|
||||
]);
|
||||
return exactUrlPathCandidate(
|
||||
const candidate = urlRelativeCandidate(
|
||||
posix.dirname(input.sourcePath),
|
||||
decoded,
|
||||
relativeCandidate(input.sourcePath, decoded),
|
||||
"URL",
|
||||
);
|
||||
return typeof candidate === "string"
|
||||
? exactUrlPathCandidate(decoded, candidate, "URL")
|
||||
: candidate;
|
||||
}
|
||||
const candidate = relativeCandidate(input.sourcePath, declared);
|
||||
return context === "module-specifier" && input.moduleKind === "import"
|
||||
? exactUrlPathCandidate(declared, candidate, "module URL")
|
||||
: candidate;
|
||||
if (context === "module-specifier" && input.moduleKind === "import") {
|
||||
const candidate = urlRelativeCandidate(
|
||||
posix.dirname(input.sourcePath),
|
||||
declared,
|
||||
);
|
||||
return typeof candidate === "string"
|
||||
? exactUrlPathCandidate(declared, candidate, "module URL")
|
||||
: candidate;
|
||||
}
|
||||
return relativeCandidate(input.sourcePath, declared);
|
||||
};
|
||||
|
||||
const decodeModuleUrlPath = (
|
||||
@@ -593,6 +595,78 @@ const relativeCandidate = (sourcePath: string, declared: string): string =>
|
||||
? declared.slice(1)
|
||||
: posix.join(posix.dirname(sourcePath), declared);
|
||||
|
||||
/**
|
||||
* A URL path during reference resolution. Above-root `..` markers are kept
|
||||
* apart from segments, so a later `..` cannot cancel an escape, while empty
|
||||
* segments stay removable until the final path is formed.
|
||||
*/
|
||||
interface UrlPathState {
|
||||
readonly escapes: number;
|
||||
readonly segments: readonly string[];
|
||||
}
|
||||
|
||||
const urlDirectoryState = (directory: string): UrlPathState => ({
|
||||
escapes: 0,
|
||||
segments: directory === "" || directory === "." ? [] : directory.split("/"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Apply URL dot segments: `..` removes the previous segment even when it is
|
||||
* empty, so `assets//../app.js` names `assets/app.js`. A final `.` or `..`
|
||||
* leaves directory form, as the URL parser does.
|
||||
*/
|
||||
const applyUrlReference = (
|
||||
base: UrlPathState,
|
||||
reference: string,
|
||||
): UrlPathState => {
|
||||
const absolute = reference.startsWith("/");
|
||||
const segments = absolute ? [] : [...base.segments];
|
||||
let escapes = absolute ? 0 : base.escapes;
|
||||
const parts = (absolute ? reference.slice(1) : reference).split("/");
|
||||
for (const [index, part] of parts.entries()) {
|
||||
if (part !== "." && part !== "..") {
|
||||
segments.push(part);
|
||||
continue;
|
||||
}
|
||||
if (part === "..")
|
||||
if (segments.length > 0) segments.pop();
|
||||
else escapes += 1;
|
||||
if (index === parts.length - 1) segments.push("");
|
||||
}
|
||||
return { escapes, segments };
|
||||
};
|
||||
|
||||
/** A relative reference replaces the base URL's final path segment. */
|
||||
const urlBaseDirectory = (base: UrlPathState): UrlPathState => ({
|
||||
escapes: base.escapes,
|
||||
segments: base.segments.slice(0, -1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Form the artifact candidate. A remaining empty segment cannot name a
|
||||
* canonical artifact file; a trailing one is directory form. Confinement
|
||||
* rejects a candidate that keeps an above-root marker.
|
||||
*/
|
||||
const urlPathCandidate = (path: UrlPathState): string | CandidateResolution => {
|
||||
const segments =
|
||||
path.segments.at(-1) === "" ? path.segments.slice(0, -1) : path.segments;
|
||||
if (segments.includes(""))
|
||||
return unresolvedCandidate("unavailable", [
|
||||
"Repeated URL path separators cannot be mapped to an exact canonical artifact file.",
|
||||
]);
|
||||
return [
|
||||
...Array.from({ length: path.escapes }, () => ".."),
|
||||
...segments,
|
||||
].join("/");
|
||||
};
|
||||
|
||||
/** Resolve a URL path reference against a canonical directory. */
|
||||
const urlRelativeCandidate = (
|
||||
directory: string,
|
||||
reference: string,
|
||||
): string | CandidateResolution =>
|
||||
urlPathCandidate(applyUrlReference(urlDirectoryState(directory), reference));
|
||||
|
||||
const bareModuleCandidate = (
|
||||
input: ResolveArtifactPathInput,
|
||||
): CandidateResolution => {
|
||||
@@ -1066,11 +1140,12 @@ const htmlCandidate = (
|
||||
return unresolvedCandidate("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 (declaredPath.startsWith("/"))
|
||||
return urlRelativeCandidate("", declaredPath);
|
||||
if (base === undefined || base === null || base === "")
|
||||
return declaredPath === ""
|
||||
? input.sourcePath
|
||||
: posix.join(posix.dirname(input.sourcePath), declaredPath);
|
||||
: urlRelativeCandidate(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.
|
||||
@@ -1083,12 +1158,21 @@ const htmlCandidate = (
|
||||
return unresolvedCandidate("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);
|
||||
if (declaredPath === "")
|
||||
return exactUrlPathCandidate(decodedBase, basePath, "HTML");
|
||||
return posix.join(htmlBaseDirectory(decodedBase, basePath), declaredPath);
|
||||
// Keep the base's empty segments and escapes until the reference is
|
||||
// applied; only the final path must name a canonical artifact file.
|
||||
const baseState = applyUrlReference(
|
||||
urlDirectoryState(posix.dirname(input.sourcePath)),
|
||||
decodedBase,
|
||||
);
|
||||
if (declaredPath === "") {
|
||||
const basePath = urlPathCandidate(baseState);
|
||||
return typeof basePath === "string"
|
||||
? exactUrlPathCandidate(decodedBase, basePath, "HTML")
|
||||
: basePath;
|
||||
}
|
||||
return urlPathCandidate(
|
||||
applyUrlReference(urlBaseDirectory(baseState), declaredPath),
|
||||
);
|
||||
};
|
||||
|
||||
const exactUrlPathCandidate = (
|
||||
@@ -1112,9 +1196,6 @@ const urlPathIsDirectory = (path: string): boolean =>
|
||||
path === "." ||
|
||||
path === "..";
|
||||
|
||||
const htmlBaseDirectory = (base: string, basePath: string): string =>
|
||||
urlPathIsDirectory(base) ? basePath : posix.dirname(basePath);
|
||||
|
||||
const confineCandidate = (candidate: string): string | CandidateResolution => {
|
||||
const normalized = normalizeJoinedLogicalPath(candidate);
|
||||
if (logicalPathEscapesRoot(normalized))
|
||||
|
||||
Reference in new issue
Block a user