fix(javascript): exclude HTML URL suffixes from path admission (#2833)

This commit is contained in:
Yann Roberto 2026-10-14 06:41:22 +08:00 committed by GitHub
parent bcb60c179e
commit 1f122c02b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 146 additions and 9 deletions

View File

@ -376,8 +376,11 @@ const resolveRuntimeSourceCandidates = (
return null;
};
const rejectDeclaration = (declared: string): CandidateResolution | null => {
if (declared.length === 0)
const rejectDeclaration = (
declared: string,
allowEmptyPath = false,
): CandidateResolution | null => {
if (declared.length === 0 && !allowEmptyPath)
return unresolvedCandidate("rejected", ["The declared path is empty."]);
if (declared.includes("\0") || declared.includes("\\"))
return unresolvedCandidate("rejected", [
@ -394,6 +397,7 @@ const contextualCandidate = (
input: ResolveArtifactPathInput,
): string | CandidateResolution => {
const { context } = input;
if (context === "html-reference") return htmlCandidate(input);
// URL suffixes are evidence, not pathname bytes. Admit only the path while
// retaining the complete declaration on the resolution result.
let declared =
@ -426,7 +430,6 @@ const contextualCandidate = (
}
const rejected = rejectDeclaration(declared);
if (rejected !== null) return rejected;
if (context === "html-reference") return htmlCandidate(input);
if (context === "module-specifier") {
if (hasScheme(declared))
return unresolvedCandidate("external", [
@ -907,6 +910,10 @@ const htmlCandidate = (
input: ResolveArtifactPathInput,
): string | CandidateResolution => {
const declared = stripQueryAndFragment(htmlUrlText(input.declaredPath));
// HTML owns URL-text normalization and pathname admission. A nonempty URL
// reference may have no pathname; suffix bytes remain declaration evidence.
const rejected = rejectDeclaration(declared, input.declaredPath !== "");
if (rejected !== null) return rejected;
if (looksExternal(declared))
return unresolvedCandidate("external", [
"External HTML references are not mapped to local artifact assets.",
@ -920,12 +927,6 @@ const htmlCandidate = (
return unresolvedCandidate("external", [
"The document base href is external, so its script reference is not a local artifact path.",
]);
// Removing URL tabs and newlines can join an encoded dot or separator that
// the raw declaration split, so admit the parsed URL text again.
if (!admitsCanonicalPathSyntax(declared))
return unresolvedCandidate("rejected", [
"Encoded dot or separator bytes are rejected before artifact path resolution.",
]);
const declaredPath = percentDecodeUrlPath(declared);
if (declaredPath === null)
return unresolvedCandidate("rejected", [

View File

@ -0,0 +1,136 @@
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
cliTest.for(["CLI", "stdio MCP"] as const)(
"admits HTML script pathnames without treating URL suffixes as paths through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-html-url-suffixes-");
const cases = [
{
source: "query.html",
script: "./app.js?redirect=%2Ffoo",
resolved: "app.js",
},
{
source: "fragment.html",
script: "./app.js#route=%2Ffoo",
resolved: "app.js",
},
{
source: "suffix-bytes.html",
script: "./app.js?value=%2e%5c%00%ff\\tail",
resolved: "app.js",
},
{
source: "url-text.html",
script: " \n./a\tpp.js?redirect=%2\nfassets ",
resolved: "app.js",
},
{
source: "self.html",
script: "?redirect=%2Ffoo",
resolved: "self.html",
},
{
source: "self-fragment.html",
script: "#route=%5Cfoo",
resolved: "self-fragment.html",
},
{
source: "base.html",
base: "assets/?redirect=%2Froot",
script: "app.js#route=%2Ffoo",
resolved: "assets/app.js",
},
{
source: "base-self.html",
base: "assets/app.js?redirect=%5Croot",
script: "?redirect=%2Ffoo",
resolved: "assets/app.js",
},
{
source: "path-separator.html",
script: "./assets%2Fapp.js?valid=1",
resolved: null,
},
{ source: "path-dot.html", script: "./%2e/app.js#valid", resolved: null },
{
source: "path-backslash.html",
script: "./assets\\app.js?valid=1",
resolved: null,
},
{
source: "path-nul.html",
script: "./app%00.js?valid=1",
resolved: null,
},
{
source: "split-separator.html",
script: "./assets%2\nfapp.js?valid=1",
resolved: null,
},
{
source: "base-separator.html",
base: "assets%2F?valid=1",
script: "app.js?redirect=%2Ffoo",
resolved: null,
},
];
await writeFixtureFiles(root, {
...Object.fromEntries(
cases.map(({ source, base, script }) => [
source,
`${base === undefined ? "" : `<base href="${base}">`}<script src="${script}"></script>`,
]),
),
"app.js": "globalThis.rootApp = true;",
"assets/app.js": "globalThis.nestedApp = true;",
});
const result = await analyzeJavaScriptDirectory(surface, cli, root);
const loads = result.graph.edges.filter(
({ relation }) => relation === "loads",
);
const unresolved = result.graph.nodes
.flatMap(({ observations }) => observations)
.filter(
({ properties }) => properties.mechanism === "html-script-reference",
);
for (const { source, base, script, resolved } of cases) {
const load = loads.find(
({ evidence }) =>
evidence.location.available &&
evidence.location.value.kind === "source-range" &&
evidence.location.value.source === source,
);
if (resolved === null) {
expect(
unresolved.find(
({ properties }) => properties.script_path === script,
),
).toMatchObject({
properties: {
script_path: script,
resolved_path: null,
resolution_status: "rejected",
},
});
expect(load).toBeUndefined();
continue;
}
expect(load).toMatchObject({
properties: {
script_path: script,
base_href: base ?? null,
resolved_path: resolved,
resolution_context: "html-reference",
resolution_status: "resolved",
},
});
}
},
);