fix(javascript): resolve query-only HTML references to their document

This commit is contained in:
morluto
2026-10-10 03:52:35 +08:00
parent 08daf056cc
commit 55a46f7c71
3 changed files with 144 additions and 5 deletions
@@ -270,7 +270,9 @@ const htmlCandidate = (
]);
if (declaredPath.startsWith("/")) return declaredPath.slice(1);
if (base === undefined || base === null || base === "")
return posix.join(posix.dirname(input.sourcePath), declaredPath);
return declaredPath === ""
? input.sourcePath
: 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.
@@ -286,23 +288,39 @@ const htmlCandidate = (
const basePath = decodedBase.startsWith("/")
? decodedBase.slice(1)
: posix.join(posix.dirname(input.sourcePath), decodedBase);
if (declaredPath === "")
return emptyHtmlReference(input, decodedBase, basePath);
return posix.join(htmlBaseDirectory(decodedBase, basePath), declaredPath);
};
const emptyHtmlReference = (
input: ResolveArtifactPathInput,
base: string,
basePath: string,
): string | ArtifactPathResolution => {
if (!htmlBaseIsDirectory(base)) return basePath;
const confined = confineCandidate(input, basePath);
if (typeof confined !== "string") return confined;
return unresolvedOutcome(input, "not-found", [
`The HTML reference resolves to directory ${confined || "."}, not an exact selected application file.`,
]);
};
/**
* 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 =>
const htmlBaseIsDirectory = (base: string): boolean =>
base.endsWith("/") ||
base.endsWith("/.") ||
base.endsWith("/..") ||
base === "." ||
base === ".."
? basePath
: posix.dirname(basePath);
base === "..";
const htmlBaseDirectory = (base: string, basePath: string): string =>
htmlBaseIsDirectory(base) ? basePath : posix.dirname(basePath);
const confineCandidate = (
input: ResolveArtifactPathInput,
@@ -33,6 +33,28 @@ const resolve = (declaredPath: string, htmlBaseHref: string) =>
});
const documentUrl = "https://artifact.test/renderer/index.html";
describe("HTML base href URL components", () => {
it.each(["?base=1", "#base-fragment"])(
"keeps the document pathname for suffix-only base href %s",
(base) => {
const resolvedBase = new URL(base, documentUrl);
expect(new URL("app.js", resolvedBase).pathname).toBe("/renderer/app.js");
expect(resolve("app.js", base)).toMatchObject({
resolved_path: "renderer/app.js",
resolution_status: "resolved",
});
const emptyPathReference = "?script=1#fragment";
expect(new URL(emptyPathReference, resolvedBase).pathname).toBe(
"/renderer/index.html",
);
expect(resolve(emptyPathReference, base)).toMatchObject({
declared_path: emptyPathReference,
resolved_path: "renderer/index.html",
resolution_status: "resolved",
});
},
);
it.each([
"/assets/?cache=/wrong/",
"/assets/#/wrong/",
@@ -0,0 +1,99 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it("resolves empty-path HTML script URLs against their canonical base path", async () => {
const root = await createTestTempDirectory("rea-html-empty-reference-");
await Promise.all([
mkdir(join(root, "base-file", "assets"), { recursive: true }),
mkdir(join(root, "base-directory", "assets"), { recursive: true }),
mkdir(join(root, "escaped"), { recursive: true }),
]);
await Promise.all([
writeFile(
join(root, "document.html"),
'<script src="?document=1"></script><script src="#document-fragment"></script>',
),
writeFile(
join(root, "base-file", "index.html"),
'<base href="assets/entry.html?base=1#base-fragment"><script src="?base-file=1#script-fragment"></script>',
),
writeFile(join(root, "base-file", "assets", "entry.html"), "<main></main>"),
writeFile(
join(root, "base-directory", "index.html"),
'<base href="assets/"><script src="?base-directory=1"></script>',
),
writeFile(
join(root, "base-directory", "assets", "index.js"),
"export const value = 1;",
),
writeFile(
join(root, "escaped", "index.html"),
'<base href="../../outside/"><script src="?escape=1"></script>',
),
]);
const result = await reconstructJavaScriptArtifact({
input_path: root,
format: "directory",
});
const graph = result.graph;
const loadFor = (scriptPath: string) =>
graph.edges.find(
(edge) =>
edge.relation === "loads" && edge.properties.script_path === scriptPath,
);
const targetPath = (edge: NonNullable<ReturnType<typeof loadFor>>) =>
graph.nodes
.find(({ node_id }) => node_id === edge.target_node_id)
?.observations.find(
({ properties }) => typeof properties.path === "string",
)?.properties.path;
const unresolvedReference = (scriptPath: string) =>
graph.nodes
.flatMap(({ observations }) => observations)
.find(
({ properties }) =>
properties.mechanism === "html-script-reference" &&
properties.script_path === scriptPath,
)?.properties;
for (const scriptPath of ["?document=1", "#document-fragment"]) {
const edge = loadFor(scriptPath);
expect(edge).toMatchObject({
properties: {
script_path: scriptPath,
resolved_path: "document.html",
resolution_status: "resolved",
},
});
if (edge !== undefined) expect(targetPath(edge)).toBe("document.html");
}
const baseFileEdge = loadFor("?base-file=1#script-fragment");
expect(baseFileEdge).toMatchObject({
properties: {
script_path: "?base-file=1#script-fragment",
base_href: "assets/entry.html?base=1#base-fragment",
resolved_path: "base-file/assets/entry.html",
resolution_status: "resolved",
},
});
if (baseFileEdge !== undefined)
expect(targetPath(baseFileEdge)).toBe("base-file/assets/entry.html");
expect(unresolvedReference("?base-directory=1")).toMatchObject({
resolution_status: "not-found",
limitations: [expect.stringContaining("resolves to directory")],
});
expect(unresolvedReference("?escape=1")).toMatchObject({
resolution_status: "rejected",
limitations: [
expect.stringContaining("escapes the canonical artifact root"),
],
});
});