diff --git a/src/browser/CdpCaptureDocuments.baseUrl.test.ts b/src/browser/CdpCaptureDocuments.baseUrl.test.ts index 26dbbeb76f..b4b6fb6aef 100644 --- a/src/browser/CdpCaptureDocuments.baseUrl.test.ts +++ b/src/browser/CdpCaptureDocuments.baseUrl.test.ts @@ -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) => { 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/"; diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index 65908b0244..3f55ebba5a 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -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; 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(); 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 = diff --git a/src/browser/CdpCaptureDomSvg.ts b/src/browser/CdpCaptureDomNamespaces.ts similarity index 81% rename from src/browser/CdpCaptureDomSvg.ts rename to src/browser/CdpCaptureDomNamespaces.ts index f196a36eb3..ab329a48a7 100644 --- a/src/browser/CdpCaptureDomSvg.ts +++ b/src/browser/CdpCaptureDomNamespaces.ts @@ -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; + readonly xmlElements: Map; } 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 => { +): Map => { const children = new Map(); const pending: XmlWalkStep[] = []; for (let node = 0; node < nodes.nodeNames.length; node += 1) { @@ -153,7 +166,7 @@ const resolveXmlElements = ( } } const bindings = new Map(); - const resolved = new Map(); + const resolved = new Map(); 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 => { - 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 diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index e6a9705903..305548da40 100644 --- a/src/browser/CdpPageCapture.ts +++ b/src/browser/CdpPageCapture.ts @@ -20,7 +20,7 @@ import { type DomCapture, mainFrameUrl, } from "./CdpCaptureDocuments.js"; -import { capturedSvgNodeKind } from "./CdpCaptureDomSvg.js"; +import { capturedSvgNodeKind } from "./CdpCaptureDomNamespaces.js"; import { allowedSanitizedUrl, delayWithCancellation,