mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00: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/";
|
||||
|
||||
@@ -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
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type DomCapture,
|
||||
mainFrameUrl,
|
||||
} from "./CdpCaptureDocuments.js";
|
||||
import { capturedSvgNodeKind } from "./CdpCaptureDomSvg.js";
|
||||
import { capturedSvgNodeKind } from "./CdpCaptureDomNamespaces.js";
|
||||
import {
|
||||
allowedSanitizedUrl,
|
||||
delayWithCancellation,
|
||||
|
||||
Reference in new issue
Block a user