683 lines
22 KiB
TypeScript
683 lines
22 KiB
TypeScript
import type { InspectWebPageInput } from "../domain/browserObservation.js";
|
|
import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
|
|
import {
|
|
allowedSanitizedUrl,
|
|
numberValue,
|
|
recordValue,
|
|
recordsValue,
|
|
requiredRecord,
|
|
cdpStringValue,
|
|
type UnknownRecord,
|
|
} from "./CdpCaptureValues.js";
|
|
import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js";
|
|
import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
|
import {
|
|
createDocumentSvgAncestry,
|
|
domSvgLink,
|
|
type DomSvgNodeQuery,
|
|
} from "./CdpCaptureDomSvg.js";
|
|
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
|
|
|
export type CapturedResource = Omit<
|
|
WebPageInspection["resources"][number],
|
|
"resource_key"
|
|
> & { readonly rawUrl: string };
|
|
|
|
/** Normalize every allowed frame, optionally selecting a leading frame. */
|
|
export const captureFrames = (
|
|
result: unknown,
|
|
allowedOrigins: ReadonlySet<string>,
|
|
maximum?: number,
|
|
completeness?: CdpCaptureCompleteness,
|
|
): {
|
|
readonly items: WebPageInspection["frames"];
|
|
} => {
|
|
const root = recordValue(recordValue(result)?.frameTree);
|
|
if (root === undefined) {
|
|
completeness?.exclude("frames", "invalid_protocol_value");
|
|
return { items: [] };
|
|
}
|
|
const items: WebPageInspection["frames"] = [];
|
|
for (const tree of walkFrameTrees(root, () =>
|
|
completeness?.exclude("frames", "invalid_protocol_value"),
|
|
)) {
|
|
const frame = recordValue(tree.frame);
|
|
const frameId = cdpStringValue(frame?.id);
|
|
const sanitized = allowedSanitizedUrl(frame?.url, allowedOrigins);
|
|
if (frame === undefined || frameId === undefined) {
|
|
completeness?.exclude("frames", "invalid_protocol_value");
|
|
continue;
|
|
}
|
|
if (sanitized === undefined || sanitized.origin === null) {
|
|
completeness?.exclude(
|
|
"frames",
|
|
exclusionReasonForUrl(cdpStringValue(frame.url)),
|
|
);
|
|
continue;
|
|
}
|
|
if (maximum !== undefined && items.length >= maximum) continue;
|
|
items.push({
|
|
frame_id: frameId,
|
|
parent_frame_id: cdpStringValue(frame.parentId) ?? null,
|
|
url: sanitized.url,
|
|
origin: sanitized.origin,
|
|
});
|
|
}
|
|
return { items };
|
|
};
|
|
|
|
/** Read the current main-frame URL from an untrusted Page.getFrameTree result. */
|
|
export const mainFrameUrl = (result: unknown): string | undefined =>
|
|
cdpStringValue(
|
|
recordValue(recordValue(requiredRecord(result).frameTree)?.frame)?.url,
|
|
);
|
|
|
|
/** Normalize every allowed resource. */
|
|
export const captureResources = (
|
|
result: unknown,
|
|
allowedOrigins: ReadonlySet<string>,
|
|
completeness?: CdpCaptureCompleteness,
|
|
): {
|
|
readonly items: readonly CapturedResource[];
|
|
} => {
|
|
const root = recordValue(recordValue(result)?.frameTree);
|
|
if (root === undefined) {
|
|
completeness?.exclude("resources", "invalid_protocol_value");
|
|
return { items: [] };
|
|
}
|
|
const items: CapturedResource[] = [];
|
|
for (const tree of walkFrameTrees(root, () =>
|
|
completeness?.exclude("resources", "invalid_protocol_value"),
|
|
)) {
|
|
for (const resource of recordsValue(tree.resources)) {
|
|
const url = allowedSanitizedUrl(resource.url, allowedOrigins);
|
|
if (url === undefined || url.origin === null) {
|
|
completeness?.exclude(
|
|
"resources",
|
|
exclusionReasonForUrl(cdpStringValue(resource.url)),
|
|
);
|
|
continue;
|
|
}
|
|
const contentSize = numberValue(resource.contentSize);
|
|
items.push({
|
|
rawUrl: cdpStringValue(resource.url) ?? "",
|
|
url: url.url,
|
|
origin: url.origin,
|
|
type: cdpStringValue(resource.type) ?? "Other",
|
|
mime_type: cdpStringValue(resource.mimeType) ?? "",
|
|
content_size:
|
|
contentSize === undefined ? null : Math.max(0, contentSize),
|
|
});
|
|
}
|
|
}
|
|
return { items };
|
|
};
|
|
|
|
export const walkFrameTrees = function* (
|
|
root: UnknownRecord,
|
|
onMalformedChild?: () => void,
|
|
): Generator<UnknownRecord> {
|
|
const pending = [root];
|
|
while (pending.length > 0) {
|
|
const tree = pending.pop();
|
|
if (tree === undefined) return;
|
|
const rawChildren = tree.childFrames;
|
|
if (rawChildren !== undefined && !Array.isArray(rawChildren))
|
|
onMalformedChild?.();
|
|
const children = Array.isArray(rawChildren)
|
|
? rawChildren.flatMap((child) => {
|
|
const parsed = recordValue(child);
|
|
if (parsed === undefined) onMalformedChild?.();
|
|
return parsed === undefined ? [] : [parsed];
|
|
})
|
|
: [];
|
|
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
const child = children[index];
|
|
if (child !== undefined) pending.push(child);
|
|
}
|
|
yield tree;
|
|
}
|
|
};
|
|
|
|
interface DomMetadataCapture {
|
|
readonly urls: WebPageInspection["metadata"]["dom_urls"];
|
|
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
|
|
readonly excludedUrls: number;
|
|
readonly unresolvedSvgNodes: number;
|
|
}
|
|
|
|
export interface DomCapture extends DomMetadataCapture {
|
|
readonly total: number;
|
|
readonly nodes: WebPageInspection["dom"]["nodes"];
|
|
}
|
|
|
|
// Bound retained projections and outstanding CDP reads independently of page size.
|
|
// Eight leaves room for the session's other cancellation listeners.
|
|
const DOM_METADATA_BATCH_SIZE = 8;
|
|
|
|
interface PendingDomMetadata {
|
|
readonly options: DomMetadataOptions;
|
|
// Undefined means no query is needed; null means the candidate has no valid ID.
|
|
readonly svgQuery: DomSvgNodeQuery | null | undefined;
|
|
}
|
|
|
|
/** Project one snapshot, requesting bounded batches of SVG identity facts. */
|
|
export function* captureDom(
|
|
result: unknown,
|
|
allowedOrigins: ReadonlySet<string>,
|
|
input: InspectWebPageInput,
|
|
completeness?: CdpCaptureCompleteness,
|
|
): Generator<
|
|
readonly DomSvgNodeQuery[],
|
|
DomCapture,
|
|
readonly (boolean | undefined)[]
|
|
> {
|
|
const root = requiredRecord(result);
|
|
const strings = Array.isArray(root.strings)
|
|
? root.strings.map(cdpStringValue)
|
|
: [];
|
|
const nodes: WebPageInspection["dom"]["nodes"] = [];
|
|
const urls: WebPageInspection["metadata"]["dom_urls"] = [];
|
|
const agentHints: WebPageInspection["metadata"]["agent_hints"] = [];
|
|
let excludedUrls = 0;
|
|
let total = 0;
|
|
let unresolvedSvgNodes = 0;
|
|
for (const document of recordsValue(root.documents)) {
|
|
const documentUrl = indexedString(strings, document.documentURL);
|
|
if (documentUrl === undefined) {
|
|
completeness?.exclude("dom", "invalid_protocol_value");
|
|
continue;
|
|
}
|
|
if (allowedSanitizedUrl(documentUrl, allowedOrigins) === undefined) {
|
|
completeness?.exclude("dom", exclusionReasonForUrl(documentUrl));
|
|
continue;
|
|
}
|
|
const baseUrlValue =
|
|
document.baseURL === undefined
|
|
? documentUrl
|
|
: indexedString(strings, document.baseURL);
|
|
if (baseUrlValue === undefined) {
|
|
completeness?.exclude("dom", "invalid_protocol_value");
|
|
continue;
|
|
}
|
|
const baseUrl = baseUrlValue || documentUrl;
|
|
const documentNodes = recordValue(document.nodes);
|
|
if (documentNodes === undefined) continue;
|
|
const rawNodeTypes = numberArray(documentNodes.nodeType);
|
|
const rawNodeNames = numberArray(documentNodes.nodeName);
|
|
const rawNodeValues = numberArray(documentNodes.nodeValue);
|
|
const rawParents = numberArray(documentNodes.parentIndex);
|
|
const rawAttributes = Array.isArray(documentNodes.attributes)
|
|
? documentNodes.attributes
|
|
: undefined;
|
|
if (
|
|
rawNodeTypes === undefined ||
|
|
rawNodeNames === undefined ||
|
|
rawNodeValues === undefined ||
|
|
rawParents === undefined ||
|
|
rawAttributes === undefined ||
|
|
rawNodeNames.length !== rawNodeTypes.length ||
|
|
rawNodeValues.length !== rawNodeTypes.length ||
|
|
rawParents.length !== rawNodeTypes.length
|
|
) {
|
|
completeness?.exclude("dom", "invalid_protocol_value");
|
|
continue;
|
|
}
|
|
if (rawAttributes.length > rawNodeTypes.length)
|
|
completeness?.exclude(
|
|
"dom",
|
|
"invalid_protocol_value",
|
|
rawAttributes.length - rawNodeTypes.length,
|
|
);
|
|
const backendNodeIds = Array.isArray(documentNodes.backendNodeId)
|
|
? documentNodes.backendNodeId
|
|
: [];
|
|
const baseIndex = total;
|
|
total += rawNodeTypes.length;
|
|
const ancestry = createDocumentSvgAncestry({
|
|
nodeTypes: rawNodeTypes,
|
|
nodeNames: rawNodeNames,
|
|
parents: rawParents,
|
|
strings,
|
|
attributes: rawAttributes,
|
|
});
|
|
const pendingMetadata: PendingDomMetadata[] = [];
|
|
for (let index = 0; index < rawNodeTypes.length; index += 1) {
|
|
const nodeType = rawNodeTypes[index];
|
|
const nodeNameIndex = rawNodeNames[index];
|
|
const nodeValueIndex = rawNodeValues[index];
|
|
const parent = rawParents[index];
|
|
const attributeIndexes = numberArray(rawAttributes[index]);
|
|
const nodeName = indexedString(strings, nodeNameIndex);
|
|
const nodeValue = indexedString(strings, nodeValueIndex);
|
|
if (
|
|
nodeType === undefined ||
|
|
nodeName === undefined ||
|
|
nodeValue === undefined ||
|
|
parent === undefined ||
|
|
parent < -1 ||
|
|
parent === index ||
|
|
parent >= rawNodeTypes.length ||
|
|
attributeIndexes === undefined ||
|
|
attributeIndexes.length % 2 !== 0 ||
|
|
attributeIndexes.some(
|
|
(value) =>
|
|
value === undefined || indexedString(strings, value) === undefined,
|
|
)
|
|
) {
|
|
completeness?.exclude("dom", "invalid_protocol_value");
|
|
continue;
|
|
}
|
|
const attributes = attributeIndexes as readonly number[];
|
|
const nodeIndex = baseIndex + index;
|
|
const attributeNames = uniqueAttributeNames(attributes, strings);
|
|
const svgLink = domSvgLink(ancestry, index);
|
|
if (svgLink.xmlDocument === undefined)
|
|
completeness?.exclude("metadata", "invalid_protocol_value");
|
|
let svgQuery: DomSvgNodeQuery | null | undefined;
|
|
if (
|
|
svgLink.xmlDocument === false &&
|
|
nodeType === 1 &&
|
|
attributeNames.includes("xlink:href") &&
|
|
!attributeNames.includes("href")
|
|
) {
|
|
const backendNodeId = numberValue(backendNodeIds[index]);
|
|
svgQuery =
|
|
backendNodeId !== undefined &&
|
|
Number.isSafeInteger(backendNodeId) &&
|
|
backendNodeId > 0
|
|
? { backendNodeId, nodeName }
|
|
: null;
|
|
}
|
|
nodes.push({
|
|
index: nodeIndex,
|
|
parent_index: parent < 0 ? -1 : baseIndex + parent,
|
|
node_type: nodeType,
|
|
node_name: nodeName,
|
|
node_value_length: nodeValue.length,
|
|
attribute_names: attributeNames,
|
|
});
|
|
pendingMetadata.push({
|
|
svgQuery,
|
|
options: {
|
|
strings,
|
|
attributes,
|
|
// The BASE element itself resolves against the document fallback URL.
|
|
baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl,
|
|
documentUrl,
|
|
nodeIndex,
|
|
nodeName,
|
|
allowedOrigins,
|
|
...svgLink,
|
|
},
|
|
});
|
|
if (
|
|
pendingMetadata.length === DOM_METADATA_BATCH_SIZE ||
|
|
index === rawNodeTypes.length - 1
|
|
) {
|
|
const metadata = yield* captureDomMetadata(
|
|
pendingMetadata,
|
|
completeness,
|
|
);
|
|
urls.push(...metadata.urls);
|
|
agentHints.push(...metadata.agentHints);
|
|
excludedUrls += metadata.excludedUrls;
|
|
unresolvedSvgNodes += metadata.unresolvedSvgNodes;
|
|
pendingMetadata.length = 0;
|
|
}
|
|
}
|
|
if (pendingMetadata.length > 0) {
|
|
const metadata = yield* captureDomMetadata(pendingMetadata, completeness);
|
|
urls.push(...metadata.urls);
|
|
agentHints.push(...metadata.agentHints);
|
|
excludedUrls += metadata.excludedUrls;
|
|
unresolvedSvgNodes += metadata.unresolvedSvgNodes;
|
|
pendingMetadata.length = 0;
|
|
}
|
|
}
|
|
return {
|
|
total,
|
|
nodes,
|
|
urls,
|
|
agentHints,
|
|
excludedUrls,
|
|
unresolvedSvgNodes,
|
|
};
|
|
}
|
|
|
|
function* captureDomMetadata(
|
|
pending: readonly PendingDomMetadata[],
|
|
completeness: CdpCaptureCompleteness | undefined,
|
|
): Generator<
|
|
readonly DomSvgNodeQuery[],
|
|
DomMetadataCapture,
|
|
readonly (boolean | undefined)[]
|
|
> {
|
|
const queries = pending.flatMap(({ svgQuery }) =>
|
|
svgQuery == null ? [] : [svgQuery],
|
|
);
|
|
const kinds = queries.length > 0 ? yield queries : [];
|
|
const urls: DomMetadataCapture["urls"] = [];
|
|
const agentHints: DomMetadataCapture["agentHints"] = [];
|
|
let queryIndex = 0;
|
|
let unresolvedSvgNodes = 0;
|
|
let excludedUrls = 0;
|
|
for (const { options, svgQuery } of pending) {
|
|
let svgElement = options.svgElement;
|
|
if (svgQuery !== undefined) {
|
|
const kind = svgQuery === null ? undefined : kinds[queryIndex++];
|
|
svgElement = kind === true;
|
|
if (kind === undefined) {
|
|
unresolvedSvgNodes += 1;
|
|
completeness?.exclude("metadata", "provider_unavailable");
|
|
}
|
|
}
|
|
const metadata = domMetadata({ ...options, svgElement });
|
|
for (const url of metadata.urls) {
|
|
urls.push(url);
|
|
if (url.destination_scope !== "approved") {
|
|
excludedUrls += 1;
|
|
completeness?.exclude(
|
|
"metadata",
|
|
url.destination_scope === "outside_policy"
|
|
? "disallowed_origin"
|
|
: "unsupported_url",
|
|
);
|
|
}
|
|
}
|
|
agentHints.push(...metadata.agentHints);
|
|
}
|
|
return { urls, agentHints, unresolvedSvgNodes, excludedUrls };
|
|
}
|
|
|
|
export const captureAccessibility = (
|
|
results: readonly unknown[],
|
|
options: {
|
|
readonly includeText: boolean;
|
|
readonly unavailable?: boolean;
|
|
},
|
|
): {
|
|
readonly total: number;
|
|
readonly nodes: WebPageInspection["accessibility"]["nodes"];
|
|
readonly textCapture: WebPageInspection["accessibility"]["text_capture"];
|
|
readonly treeIncomplete: boolean;
|
|
} => {
|
|
const all = results.flatMap((result) =>
|
|
recordsValue(requiredRecord(result).nodes),
|
|
);
|
|
const nodeIds = new Set(
|
|
all.flatMap((node) => {
|
|
const nodeId = cdpStringValue(node.nodeId);
|
|
return nodeId === undefined ? [] : [nodeId];
|
|
}),
|
|
);
|
|
let excludedFields = 0;
|
|
const nodes = all.map((node) => {
|
|
const captureText = (value: unknown): string | null => {
|
|
const raw = cdpStringValue(recordValue(value)?.value);
|
|
if (raw === undefined) return null;
|
|
if (!options.includeText) {
|
|
excludedFields += 1;
|
|
return null;
|
|
}
|
|
return raw;
|
|
};
|
|
return {
|
|
node_id: cdpStringValue(node.nodeId) ?? "",
|
|
parent_id: cdpStringValue(node.parentId) ?? null,
|
|
role: axText(node.role),
|
|
name: captureText(node.name),
|
|
description: captureText(node.description),
|
|
ignored: node.ignored === true,
|
|
states: accessibilityStates(node.properties),
|
|
};
|
|
});
|
|
return {
|
|
total: all.length,
|
|
nodes,
|
|
treeIncomplete: all.some(
|
|
(node) =>
|
|
Array.isArray(node.childIds) &&
|
|
node.childIds.some((childId) => {
|
|
const id = cdpStringValue(childId);
|
|
return id !== undefined && !nodeIds.has(id);
|
|
}),
|
|
),
|
|
textCapture: {
|
|
status: options.unavailable
|
|
? "unavailable"
|
|
: !options.includeText
|
|
? "not_approved"
|
|
: "included",
|
|
retained_bytes: nodes.reduce(
|
|
(total, node) =>
|
|
total +
|
|
Buffer.byteLength(node.name ?? "") +
|
|
Buffer.byteLength(node.description ?? ""),
|
|
0,
|
|
),
|
|
excluded_fields: excludedFields,
|
|
},
|
|
};
|
|
};
|
|
|
|
const ACCESSIBILITY_STATE_NAMES = new Set([
|
|
"atomic",
|
|
"autocomplete",
|
|
"busy",
|
|
"checked",
|
|
"disabled",
|
|
"editable",
|
|
"expanded",
|
|
"focusable",
|
|
"focused",
|
|
"hasPopup",
|
|
"hidden",
|
|
"invalid",
|
|
"level",
|
|
"live",
|
|
"modal",
|
|
"multiline",
|
|
"multiselectable",
|
|
"orientation",
|
|
"pressed",
|
|
"readonly",
|
|
"required",
|
|
"selected",
|
|
"valuemax",
|
|
"valuemin",
|
|
]);
|
|
|
|
const accessibilityStates = (
|
|
value: unknown,
|
|
): WebPageInspection["accessibility"]["nodes"][number]["states"] => {
|
|
const states = new Map<string, boolean | number | string>();
|
|
for (const property of recordsValue(value)) {
|
|
const name = cdpStringValue(property.name);
|
|
if (name === undefined || !ACCESSIBILITY_STATE_NAMES.has(name)) continue;
|
|
const raw = recordValue(property.value)?.value;
|
|
const state =
|
|
typeof raw === "boolean"
|
|
? raw
|
|
: typeof raw === "number"
|
|
? numberValue(raw)
|
|
: typeof raw === "string"
|
|
? raw
|
|
: undefined;
|
|
if (state !== undefined) states.set(name, state);
|
|
}
|
|
return [...states]
|
|
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
|
|
.map(([name, state]) => ({ name, value: state }));
|
|
};
|
|
|
|
const axText = (value: unknown): string | null =>
|
|
cdpStringValue(recordValue(value)?.value) ?? null;
|
|
|
|
const indexedString = (
|
|
strings: readonly (string | undefined)[],
|
|
index: unknown,
|
|
): string | undefined => {
|
|
const integer = numberValue(index);
|
|
if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0)
|
|
return undefined;
|
|
return strings[integer];
|
|
};
|
|
|
|
const uniqueAttributeNames = (
|
|
attributeIndexes: readonly number[],
|
|
strings: readonly (string | undefined)[],
|
|
): string[] => {
|
|
const names: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (let index = 0; index < attributeIndexes.length; index += 2) {
|
|
const name = indexedString(strings, attributeIndexes[index]);
|
|
if (name === undefined) continue;
|
|
if (seen.has(name)) continue;
|
|
seen.add(name);
|
|
names.push(name);
|
|
}
|
|
return names;
|
|
};
|
|
|
|
const numberArray = (
|
|
value: unknown,
|
|
): readonly (number | undefined)[] | undefined => {
|
|
if (!Array.isArray(value)) return undefined;
|
|
return value.map((item) => {
|
|
const number = numberValue(item);
|
|
return number !== undefined && Number.isSafeInteger(number)
|
|
? number
|
|
: undefined;
|
|
});
|
|
};
|
|
|
|
interface DomMetadataOptions {
|
|
readonly strings: readonly (string | undefined)[];
|
|
readonly attributes: readonly number[];
|
|
readonly baseUrl: string;
|
|
readonly documentUrl: string;
|
|
readonly nodeIndex: number;
|
|
readonly nodeName: string;
|
|
readonly allowedOrigins: ReadonlySet<string>;
|
|
readonly svgElement: boolean;
|
|
readonly xmlDocument: boolean | undefined;
|
|
readonly xlinkHref: string | undefined;
|
|
}
|
|
|
|
// Chrome reads an SVG XLink href only when href itself is absent, and an
|
|
// empty href still wins. HTML keeps the literal attribute name `xlink:href`.
|
|
// XML uses any prefix bound to the XLink namespace. Both are stored as href.
|
|
const hrefAttributeValue = (
|
|
pairs: ReadonlyMap<string, string>,
|
|
attribute: string,
|
|
options: DomMetadataOptions,
|
|
): string | undefined => {
|
|
const declared = pairs.get(attribute);
|
|
if (declared !== undefined) return declared;
|
|
if (attribute !== "href" || !options.svgElement) return undefined;
|
|
return options.xmlDocument ? options.xlinkHref : pairs.get("xlink:href");
|
|
};
|
|
|
|
const domMetadata = (
|
|
options: DomMetadataOptions,
|
|
): {
|
|
readonly urls: WebPageInspection["metadata"]["dom_urls"];
|
|
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
|
|
} => {
|
|
const {
|
|
strings,
|
|
attributes,
|
|
baseUrl,
|
|
documentUrl,
|
|
nodeIndex,
|
|
nodeName,
|
|
allowedOrigins,
|
|
} = options;
|
|
const pairs = new Map<string, string>();
|
|
for (let index = 0; index + 1 < attributes.length; index += 2) {
|
|
// Chrome already folds HTML names; SVG/XML names remain case-sensitive.
|
|
const name = indexedString(strings, attributes[index]);
|
|
const value = indexedString(strings, attributes[index + 1]);
|
|
if (name === undefined || value === undefined) continue;
|
|
// HTML keeps the first declaration. A later repeat must not move dom_urls.
|
|
if (!pairs.has(name)) pairs.set(name, value);
|
|
}
|
|
const urls: WebPageInspection["metadata"]["dom_urls"] = [];
|
|
for (const attribute of domUrlAttributes) {
|
|
const value = hrefAttributeValue(pairs, attribute, options);
|
|
if (value === undefined) continue;
|
|
const tagName = nodeName.toLowerCase();
|
|
// Chrome strips HTML whitespace for FORM.action, while submit controls
|
|
// use the document URL only for an exactly empty formaction attribute.
|
|
const usesDocumentUrl =
|
|
(tagName === "form" &&
|
|
attribute === "action" &&
|
|
/^[\t\n\f\r ]*$/u.test(value)) ||
|
|
((tagName === "button" || tagName === "input") &&
|
|
attribute === "formaction" &&
|
|
value === "");
|
|
const destination = domDestination(
|
|
usesDocumentUrl ? documentUrl : value,
|
|
baseUrl,
|
|
allowedOrigins,
|
|
);
|
|
urls.push({
|
|
node_index: nodeIndex,
|
|
attribute,
|
|
url: destination.url,
|
|
destination_scope: destination.scope,
|
|
});
|
|
}
|
|
const rel = (pairs.get("rel") ?? "")
|
|
.toLowerCase()
|
|
// HTML link type tokens are separated by ASCII whitespace only.
|
|
.split(/[\t\n\f\r ]+/u)
|
|
.filter((value) => agentRelValues.includes(value));
|
|
const href = urls.find(({ attribute }) => attribute === "href")?.url ?? null;
|
|
const agentHints =
|
|
nodeName.toLowerCase() === "link" && rel.length > 0
|
|
? rel.map((declaration) => ({
|
|
mechanism: "dom_link_rel" as const,
|
|
declaration,
|
|
url: href,
|
|
trust: "page-declared-untrusted" as const,
|
|
}))
|
|
: [];
|
|
return { urls, agentHints };
|
|
};
|
|
|
|
const domDestination = (
|
|
value: string,
|
|
baseUrl: string,
|
|
allowedOrigins: ReadonlySet<string>,
|
|
): {
|
|
readonly url: string | null;
|
|
readonly scope: "approved" | "outside_policy" | "unsupported";
|
|
} => {
|
|
try {
|
|
const parsed = new URL(value, baseUrl);
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
return { url: null, scope: "unsupported" };
|
|
if (!allowedOrigins.has(parsed.origin))
|
|
return { url: null, scope: "outside_policy" };
|
|
return {
|
|
url: allowedSanitizedUrl(parsed.href, allowedOrigins)?.url ?? null,
|
|
scope: "approved",
|
|
};
|
|
} catch (cause: unknown) {
|
|
// Unparseable URLs are out of scope, not failures.
|
|
void cause;
|
|
return { url: null, scope: "unsupported" };
|
|
}
|
|
};
|
|
|
|
const domUrlAttributes = [
|
|
"href",
|
|
"src",
|
|
"action",
|
|
"formaction",
|
|
"poster",
|
|
] as const;
|
|
const agentRelValues = ["mcp", "model-context", "ai-plugin", "service-desc"];
|