From 20554508db732abfb0c98d4712c1a7b2ccd175a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Chaonan=E2=80=9D?= Date: Thu, 20 Aug 2026 20:45:19 +0800 Subject: [PATCH] fix(vom): omit raw AX/DOM trees from record-safe observations Return only frame identity and node geometry for matching so a serialized capture result cannot leak form values. --- .../src/tools/__tests__/observation.test.ts | 171 +++++++++++++++++- .../src/tools/capture-vom-observation.ts | 2 + apps/extension/src/tools/observation.ts | 51 +++++- 3 files changed, 214 insertions(+), 10 deletions(-) diff --git a/apps/extension/src/tools/__tests__/observation.test.ts b/apps/extension/src/tools/__tests__/observation.test.ts index 7fb6ccf..fd96156 100644 --- a/apps/extension/src/tools/__tests__/observation.test.ts +++ b/apps/extension/src/tools/__tests__/observation.test.ts @@ -2909,6 +2909,87 @@ describe("handleSnapshot", () => { ], }; } + + function secretsSnapshotReply(secrets: { + email: string; + password: string; + otp: string; + card: string; + }) { + const S = [ + "html", + "body", + "input", + "position", + "static", + "pointer-events", + "auto", + "type", + "text", + "password", + "autocomplete", + "one-time-code", + "cc-number", + "value", + secrets.email, + secrets.password, + secrets.otp, + secrets.card, + ]; + const i = (s: string) => S.indexOf(s); + const style = [i("static"), i("auto"), i("auto")]; + return { + strings: S, + documents: [ + { + nodes: { + parentIndex: [-1, 0, 1, 1, 1, 1], + nodeName: [i("html"), i("body"), i("input"), i("input"), i("input"), i("input")], + backendNodeId: [10, 11, 12, 13, 14, 15], + attributes: [ + [], + [], + [i("type"), i("text"), i("value"), i(secrets.email)], + [i("type"), i("password"), i("value"), i(secrets.password)], + [ + i("type"), + i("text"), + i("autocomplete"), + i("one-time-code"), + i("value"), + i(secrets.otp), + ], + [ + i("type"), + i("text"), + i("autocomplete"), + i("cc-number"), + i("value"), + i(secrets.card), + ], + ], + inputValue: { + index: [2, 3, 4, 5], + value: [i(secrets.email), i(secrets.password), i(secrets.otp), i(secrets.card)], + }, + }, + layout: { + nodeIndex: [1, 2, 3, 4, 5], + styles: [style, style, style, style, style], + bounds: [ + [0, 0, 1000, 800], + [10, 10, 200, 32], + [10, 50, 200, 32], + [10, 90, 200, 32], + [10, 130, 200, 32], + ], + paintOrders: [0, 1, 2, 3, 4], + }, + }, + ], + }; + } + const VP_METRICS = { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 }, }; @@ -3063,20 +3144,96 @@ describe("handleSnapshot", () => { line: expect.any(Number), }); expect(result.rootFrameId).toBe("root"); - expect(result.frameDocuments).toHaveLength(1); - expect(result.frameDocuments[0]?.domNodes.some((node) => node.backendNodeId === 13)).toBe(true); + expect(result).not.toHaveProperty("frameDocuments"); + expect(result.frames).toEqual([{ frameId: "root", target: { tabId: 4 } }]); + expect(result.nodes.find((node) => node.backendNodeId === 13)).toMatchObject({ + frameId: "root", + backendNodeId: 13, + tag: "input", + rect: { x: 400, y: 300, w: 200, h: 40 }, + }); }); - it("keeps frame documents and rendered refs on the same frame identity", async () => { + it("does not leak form secrets through the record-safe observation payload", async () => { + const secrets = { + email: "user@example.com", + password: "hunter2", + otp: "847291", + card: "4111111111111111", + }; + const ax: CdpAxNode[] = [ + { + nodeId: "root", + role: { type: "role", value: "RootWebArea" }, + backendDOMNodeId: 11, + childIds: ["email", "password", "otp", "card"], + }, + { + nodeId: "email", + parentId: "root", + role: { type: "role", value: "textbox" }, + name: { type: "computedString", value: "Email" }, + value: { value: secrets.email }, + backendDOMNodeId: 12, + }, + { + nodeId: "password", + parentId: "root", + role: { type: "role", value: "textbox" }, + name: { type: "computedString", value: "Password" }, + value: { value: secrets.password }, + backendDOMNodeId: 13, + }, + { + nodeId: "otp", + parentId: "root", + role: { type: "role", value: "textbox" }, + name: { type: "computedString", value: "Code" }, + value: { value: secrets.otp }, + backendDOMNodeId: 14, + }, + { + nodeId: "card", + parentId: "root", + role: { type: "role", value: "textbox" }, + name: { type: "computedString", value: "Card" }, + value: { value: secrets.card }, + backendDOMNodeId: 15, + }, + ]; + const deps = makeOverlayDeps(ax, secretsSnapshotReply(secrets), VP_METRICS); + + const result = await captureVomObservation(deps.cdp, 4, "https://example.com", { + redactValues: true, + }); + const dumped = JSON.stringify(result); + + expect(result).not.toHaveProperty("frameDocuments"); + expect(dumped).not.toContain(secrets.email); + expect(dumped).not.toContain(secrets.password); + expect(dumped).not.toContain(secrets.otp); + expect(dumped).not.toContain(secrets.card); + expect(dumped).not.toContain("formValue"); + expect(dumped).not.toContain("axNodes"); + expect(result.text).toContain("•••"); + }); + + it("keeps frames and rendered refs on the same frame identity", async () => { const deps = makeFrameAwareDeps(); const result = await captureVomObservation(deps.cdp, 4, "https://example.com"); expect(result.rootFrameId).toBe("main"); - expect(result.frameDocuments).toHaveLength(2); - expect(result.frameDocuments.find((document) => document.frameId === "child")?.target).toEqual({ - tabId: 4, - sessionId: "child-session", + expect(result.frames).toHaveLength(2); + expect(result.frames.find((frame) => frame.frameId === "child")).toEqual({ + frameId: "child", + parentFrameId: "main", + ownerBackendNodeId: 12, + target: { tabId: 4, sessionId: "child-session" }, + }); + expect(result.nodes.find((node) => node.backendNodeId === 22)).toMatchObject({ + frameId: "child", + tag: "button", }); expect(result.refs.find((ref) => ref.backendNodeId === 22)).toMatchObject({ frameId: "child", diff --git a/apps/extension/src/tools/capture-vom-observation.ts b/apps/extension/src/tools/capture-vom-observation.ts index 10b6d69..c2bc08f 100644 --- a/apps/extension/src/tools/capture-vom-observation.ts +++ b/apps/extension/src/tools/capture-vom-observation.ts @@ -1,4 +1,6 @@ export { + type CaptureVomFrame, + type CaptureVomNode, type CaptureVomObservationOptions, type CaptureVomObservationResult, captureVomObservation, diff --git a/apps/extension/src/tools/observation.ts b/apps/extension/src/tools/observation.ts index 75d346f..f8a25ca 100644 --- a/apps/extension/src/tools/observation.ts +++ b/apps/extension/src/tools/observation.ts @@ -8,6 +8,7 @@ import { applyVomInteractionRecovery, type CondSurface, isVomReferenceNode, + type Rect, type RenderedRef, renderVom, type VomNode, @@ -1590,15 +1591,58 @@ export interface CaptureVomObservationOptions extends VomOptions { signal?: AbortSignal; } +/** Frame identity needed to click an `@eN` that lives in an iframe. */ +export interface CaptureVomFrame { + frameId: string; + target: CdpTarget; + parentFrameId?: string; + ownerBackendNodeId?: number; +} + +/** Geometry index for matching a recorded click to a captured node. */ +export interface CaptureVomNode { + frameId: string; + backendNodeId: number; + tag: string; + rect: Rect | null; +} + +/** + * Record-safe observation: rendered VOM text plus the matching index. + * Raw AX/DOM trees are not returned — they carry form values. + */ export interface CaptureVomObservationResult { text: string; refs: RenderedRef[]; truncated: boolean; rootFrameId: string; - frameDocuments: CapturedFrameDocument[]; + frames: CaptureVomFrame[]; + nodes: CaptureVomNode[]; surfaceProbes?: CapturedSurfaceProbe[]; } +function recordSafeFrames(documents: CapturedFrameDocument[]): CaptureVomFrame[] { + return documents.map((document) => ({ + frameId: document.frameId, + target: document.target, + ...(document.parentFrameId ? { parentFrameId: document.parentFrameId } : {}), + ...(document.ownerBackendNodeId !== undefined + ? { ownerBackendNodeId: document.ownerBackendNodeId } + : {}), + })); +} + +function recordSafeNodes(documents: CapturedFrameDocument[]): CaptureVomNode[] { + return documents.flatMap((document) => + document.domNodes.map((node) => ({ + frameId: document.frameId, + backendNodeId: node.backendNodeId, + tag: node.tag, + rect: node.rect, + })), + ); +} + export async function captureVomObservation( cdp: CdpRunner, tabId: number, @@ -1628,7 +1672,8 @@ export async function captureVomObservation( refs: rendered.refs, truncated: rendered.truncated, rootFrameId: captured.rootFrameId ?? documents[0]?.frameId ?? "root", - frameDocuments: documents, + frames: recordSafeFrames(documents), + nodes: recordSafeNodes(documents), surfaceProbes: captured.surfaceProbes, }; } @@ -1674,7 +1719,7 @@ async function handleVomObservation( }); throwIfAborted(signal, toolName); const targetByFrameId = new Map( - observation.frameDocuments.map((document) => [document.frameId, document.target]), + observation.frames.map((frame) => [frame.frameId, frame.target]), ); ctx.refStore.replace( observation.refs.map((ref) => {