fix(browser): resolve prefixed XHTML form actions

Fixes #6561
This commit is contained in:
morluto committed 2026-10-23 18:07:46 +08:00
1 parent ba7c3dafab
commit cde858d546
4 files changed
+128 -32

No files matched your search

@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
import { captureDom } from "./CdpCaptureDocuments.js";
import { capturedSvgNodeKind } from "./CdpCaptureDomSvg.js";
import { capturedSvgNodeKind } from "./CdpCaptureDomNamespaces.js";
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
const origin = "https://app.example.test";
@@ -58,6 +58,43 @@ const capture = (value: ReturnType<typeof snapshot>) => {
return step.value;
};
const prefixedXhtmlSnapshot = () => {
const strings = [
`${origin}/root/prefixed.xhtml`,
`${origin}/assets/`,
"#document",
"h:html",
"h:head",
"h:base",
"h:body",
"h:form",
"h:button",
"",
"xmlns:h",
"http://www.w3.org/1999/xhtml",
"href",
"/assets/",
"action",
"formaction",
];
return {
strings,
documents: [
{
documentURL: 0,
baseURL: 1,
nodes: {
nodeType: [9, 1, 1, 1, 1, 1, 1],
nodeName: [2, 3, 4, 5, 6, 7, 8],
nodeValue: [9, 9, 9, 9, 9, 9, 9],
parentIndex: [-1, 0, 1, 2, 1, 4, 5],
attributes: [[], [10, 11], [], [12, 13], [], [14, 9], [15, 9]],
},
},
],
};
};
describe("DOM metadata document base URLs", () => {
it("splits link relations only on HTML ASCII whitespace", () => {
const strings = [
@@ -193,6 +230,37 @@ describe("DOM metadata document base URLs", () => {
});
});
describe("prefixed XHTML form destinations", () => {
it("resolves empty actions to the document URL", () => {
const result = captureDom(
prefixedXhtmlSnapshot(),
new Set([origin]),
input,
).next();
if (!result.done) throw new Error("Unexpected SVG identity query");
expect(result.value.urls).toEqual([
{
node_index: 3,
attribute: "href",
url: `${origin}/assets/`,
destination_scope: "approved",
},
{
node_index: 5,
attribute: "action",
url: `${origin}/root/prefixed.xhtml`,
destination_scope: "approved",
},
{
node_index: 6,
attribute: "formaction",
url: `${origin}/root/prefixed.xhtml`,
destination_scope: "approved",
},
]);
});
});
describe("empty form destinations with a document base URL", () => {
it("keeps an empty form action approved when the document base is foreign", () => {
const foreign = "https://cdn.example.test/assets/";
+20 -9
View File
@@ -25,10 +25,11 @@ import type { BrowserExclusionReason } from "../domain/browserCompleteness.js";
import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js";
import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
import {
createDocumentSvgAncestry,
domSvgLink,
createDocumentNamespaceAncestry,
domXmlElement,
type DomSvgNodeQuery,
} from "./CdpCaptureDomSvg.js";
XHTML_NAMESPACE,
} from "./CdpCaptureDomNamespaces.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
export type CapturedResource = Omit<
@@ -339,7 +340,7 @@ export function* captureDom(
);
const baseIndex = total;
total += nodeTypes.length;
const ancestry = createDocumentSvgAncestry(documentNodes);
const ancestry = createDocumentNamespaceAncestry(documentNodes);
const pendingMetadata: PendingDomMetadata[] = [];
for (let index = 0; index < nodeTypes.length; index += 1) {
const admitted = snapshotNodeAt(documentNodes, index);
@@ -351,12 +352,12 @@ export function* captureDom(
admitted.value;
const nodeIndex = baseIndex + index;
const attributeNames = [...new Set(attributes.map(([name]) => name))];
const svgLink = domSvgLink(ancestry, index);
if (svgLink.xmlDocument === undefined)
const domElement = domXmlElement(ancestry, index);
if (domElement.xmlDocument === undefined)
completeness?.exclude("metadata", "invalid_protocol_value");
let svgQuery: DomSvgNodeQuery | null | undefined;
if (
svgLink.xmlDocument === false &&
domElement.xmlDocument === false &&
nodeType === 1 &&
attributeNames.includes("xlink:href") &&
!attributeNames.includes("href")
@@ -383,7 +384,7 @@ export function* captureDom(
nodeIndex,
nodeName,
allowedOrigins,
...svgLink,
...domElement,
},
});
if (
@@ -674,6 +675,8 @@ interface DomMetadataOptions {
readonly allowedOrigins: ReadonlySet<string>;
readonly svgElement: boolean;
readonly xmlDocument: boolean | undefined;
readonly elementNamespace: string | undefined;
readonly localName: string | undefined;
readonly xlinkHref: string | undefined;
}
@@ -704,6 +707,9 @@ const domMetadata = (
nodeIndex,
nodeName,
allowedOrigins,
xmlDocument,
elementNamespace,
localName,
} = options;
const pairs = new Map<string, string>();
for (const [name, value] of attributes) {
@@ -714,7 +720,12 @@ const domMetadata = (
for (const attribute of domUrlAttributes) {
const value = hrefAttributeValue(pairs, attribute, options);
if (value === undefined) continue;
const tagName = nodeName.toLowerCase();
const tagName =
xmlDocument === false
? nodeName.toLowerCase()
: xmlDocument === true && elementNamespace === XHTML_NAMESPACE
? (localName ?? "")
: "";
// Chrome strips HTML whitespace for FORM.action, while submit controls
// use the document URL only for an exactly empty formaction attribute.
const usesDocumentUrl =
@@ -6,20 +6,23 @@ import type {
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
export const XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
type DomSnapshotNodes = Pick<
SnapshotDocumentNodes,
"nodeTypes" | "nodeNames" | "parents" | "attributes"
>;
interface XmlSvgLink {
interface XmlElementNamespace {
readonly svgElement: boolean;
readonly elementNamespace: string | undefined;
readonly localName: string | undefined;
readonly xlinkHref: string | undefined;
}
interface DocumentSvgAncestry {
interface DocumentNamespaceAncestry {
readonly xml: boolean | undefined;
readonly xmlLinks: Map<number, XmlSvgLink>;
readonly xmlElements: Map<number, XmlElementNamespace>;
}
export interface DomSvgNodeQuery {
@@ -47,9 +50,11 @@ export const capturedSvgNodeKind = (
: undefined;
};
interface DomSvgLink {
interface DomXmlElement {
readonly svgElement: boolean;
readonly xmlDocument: boolean | undefined;
readonly elementNamespace: string | undefined;
readonly localName: string | undefined;
readonly xlinkHref: string | undefined;
}
@@ -82,24 +87,26 @@ const xmlDocument = (nodes: DomSnapshotNodes): boolean | undefined => {
return false;
};
export const createDocumentSvgAncestry = (
export const createDocumentNamespaceAncestry = (
nodes: DomSnapshotNodes,
): DocumentSvgAncestry => {
): DocumentNamespaceAncestry => {
const xml = xmlDocument(nodes);
return {
xml,
xmlLinks: xml === true ? resolveXmlElements(nodes) : new Map(),
xmlElements: xml === true ? resolveXmlElements(nodes) : new Map(),
};
};
export const domSvgLink = (
ancestry: DocumentSvgAncestry,
export const domXmlElement = (
ancestry: DocumentNamespaceAncestry,
index: number,
): DomSvgLink => {
): DomXmlElement => {
if (ancestry.xml === false) {
return {
svgElement: false,
xmlDocument: false,
elementNamespace: undefined,
localName: undefined,
xlinkHref: undefined,
};
}
@@ -107,20 +114,26 @@ export const domSvgLink = (
return {
svgElement: false,
xmlDocument: undefined,
elementNamespace: undefined,
localName: undefined,
xlinkHref: undefined,
};
}
const resolved = ancestry.xmlLinks.get(index);
const resolved = ancestry.xmlElements.get(index);
if (resolved === undefined) {
return {
svgElement: false,
xmlDocument: undefined,
elementNamespace: undefined,
localName: undefined,
xlinkHref: undefined,
};
}
return {
svgElement: resolved.svgElement,
xmlDocument: true,
elementNamespace: resolved.elementNamespace,
localName: resolved.localName,
xlinkHref: resolved.xlinkHref,
};
};
@@ -139,7 +152,7 @@ type XmlWalkStep =
// on the active path are retained, rather than cumulative maps at every node.
const resolveXmlElements = (
nodes: DomSnapshotNodes,
): Map<number, XmlSvgLink> => {
): Map<number, XmlElementNamespace> => {
const children = new Map<number, number[]>();
const pending: XmlWalkStep[] = [];
for (let node = 0; node < nodes.nodeNames.length; node += 1) {
@@ -153,7 +166,7 @@ const resolveXmlElements = (
}
}
const bindings = new Map<string, string>();
const resolved = new Map<number, XmlSvgLink>();
const resolved = new Map<number, XmlElementNamespace>();
while (pending.length > 0) {
const step = pending.pop();
if (step === undefined) break;
@@ -169,11 +182,13 @@ const resolveXmlElements = (
if (source === undefined) continue;
const attributes = source.attributes;
const restore = applyDeclarations(bindings, attributes);
const expandedName = elementExpandedName(source.nodeName, bindings);
const svgElement =
source.nodeType === 1 &&
elementNamespace(source.nodeName, bindings) === SVG_NAMESPACE;
source.nodeType === 1 && expandedName?.namespace === SVG_NAMESPACE;
resolved.set(node, {
svgElement,
elementNamespace: expandedName?.namespace,
localName: expandedName?.localName,
xlinkHref: svgElement ? xlinkHrefValue(attributes, bindings) : undefined,
});
pending.push({ restore });
@@ -211,13 +226,14 @@ const xmlnsPrefix = (name: string | undefined): string | undefined => {
return prefix;
};
const elementNamespace = (
const elementExpandedName = (
nodeName: string | undefined,
bindings: ReadonlyMap<string, string>,
): string => {
if (nodeName === undefined) return "";
): { readonly namespace: string; readonly localName: string } | undefined => {
if (nodeName === undefined) return undefined;
const colon = nodeName.indexOf(":");
if (colon < 0) return bindings.get("") ?? "";
if (colon < 0)
return { namespace: bindings.get("") ?? "", localName: nodeName };
const prefix = nodeName.slice(0, colon);
const local = nodeName.slice(colon + 1);
if (
@@ -226,8 +242,9 @@ const elementNamespace = (
local.includes(":") ||
prefix === "xmlns"
)
return "";
return bindings.get(prefix) ?? "";
return undefined;
const namespace = bindings.get(prefix);
return namespace === undefined ? undefined : { namespace, localName: local };
};
// First prefixed href bound to the XLink namespace. Duplicate names keep the
+1 -1
View File
@@ -20,7 +20,7 @@ import {
type DomCapture,
mainFrameUrl,
} from "./CdpCaptureDocuments.js";
import { capturedSvgNodeKind } from "./CdpCaptureDomSvg.js";
import { capturedSvgNodeKind } from "./CdpCaptureDomNamespaces.js";
import {
allowedSanitizedUrl,
delayWithCancellation,