Skip to content

Commit 3d4761b

Browse files
committed
fix: omit private tab pointer events from sidecar
1 parent 50d42df commit 3d4761b

3 files changed

Lines changed: 278 additions & 1 deletion

File tree

‎.github/workflows/recording-reliability.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ jobs:
9090
run: bunx playwright install --with-deps chromium
9191
- name: Build Chrome extension
9292
run: bun run --filter @cap/chrome-extension build
93+
- name: Verify private tab input is omitted
94+
working-directory: apps/chrome-extension
95+
run: bun src/content/input-events-browser-replay.ts
9396
- name: Replay paired capture and recovery in the built extension
9497
working-directory: apps/chrome-extension
9598
run: |
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import assert from "node:assert/strict";
2+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join, resolve } from "node:path";
5+
import { chromium } from "@playwright/test";
6+
import { parseCapturedTabInputBatch } from "../shared/input-events";
7+
8+
const html = `<!doctype html>
9+
<html>
10+
<head>
11+
<style>
12+
body { margin: 0; }
13+
.control { position: absolute; left: 40px; width: 220px; height: 30px; }
14+
</style>
15+
</head>
16+
<body>
17+
<input id="password" class="control" type="password" aria-label="Password" style="top: 40px">
18+
<div id="editable" class="control" contenteditable style="top: 80px">Private text</div>
19+
<div id="textbox" class="control" role="textbox" style="top: 120px">Custom text</div>
20+
<div id="pin" class="control" aria-label="PIN widget" style="top: 160px">PIN</div>
21+
<button id="secret" class="control" aria-label="Secret button" style="top: 200px">Secret</button>
22+
<label id="email-label" class="control" for="email" style="top: 240px">Email address</label>
23+
<input id="email" class="control" type="email" style="top: 280px">
24+
<form id="payment" class="control" style="top: 320px; height: 80px">
25+
<input id="card" autocomplete="cc-number" style="width: 210px; height: 30px">
26+
<button id="pay" type="button" style="position: absolute; top: 40px; left: 0; width: 210px; height: 30px">Confirm payment</button>
27+
</form>
28+
<div id="shadow-host" class="control" style="top: 420px"></div>
29+
<div id="cap-extension-recorder-overlay" class="control" style="top: 460px">
30+
<button id="overlay-button" style="width: 210px; height: 30px">Recorder control</button>
31+
</div>
32+
<button id="public" class="control" style="left: 420px; top: 420px">Public action</button>
33+
<form class="control" style="left: 420px; top: 470px; height: 80px">
34+
<input name="shipping-address" style="width: 210px; height: 30px">
35+
<button id="shipping" type="button" style="position: absolute; top: 40px; left: 0; width: 210px; height: 30px">Update shipping</button>
36+
</form>
37+
<script>
38+
const shadowInput = document.createElement("input");
39+
shadowInput.setAttribute("aria-label", "Shadow private field");
40+
shadowInput.style.cssText = "width: 210px; height: 30px";
41+
document.getElementById("shadow-host").attachShadow({ mode: "open" }).append(shadowInput);
42+
</script>
43+
</body>
44+
</html>`;
45+
46+
const tempDirectory = await mkdtemp(join(tmpdir(), "cap-input-privacy-"));
47+
const entry = join(tempDirectory, "entry.ts");
48+
await writeFile(
49+
entry,
50+
`import { initTabInputCapture } from ${JSON.stringify(resolve(import.meta.dir, "input-events.ts"))};\ninitTabInputCapture();\n`,
51+
);
52+
53+
const bundled = await Bun.build({
54+
entrypoints: [entry],
55+
target: "browser",
56+
format: "iife",
57+
splitting: false,
58+
});
59+
assert.ok(bundled.success, bundled.logs.map((log) => log.message).join("\n"));
60+
const contentScript = await bundled.outputs[0]?.text();
61+
assert.ok(contentScript);
62+
63+
const server = Bun.serve({
64+
hostname: "127.0.0.1",
65+
port: 0,
66+
fetch() {
67+
return new Response(html, {
68+
headers: { "Content-Type": "text/html; charset=utf-8" },
69+
});
70+
},
71+
});
72+
const browser = await chromium.launch({ headless: true });
73+
74+
try {
75+
const page = await browser.newPage({
76+
viewport: { width: 900, height: 600 },
77+
});
78+
const pageErrors: string[] = [];
79+
page.on("pageerror", (error) => pageErrors.push(error.message));
80+
await page.addInitScript(() => {
81+
type ContentListener = (
82+
message: { type: string; recordingId?: string },
83+
sender: unknown,
84+
respond: (value: unknown) => void,
85+
) => boolean;
86+
(window as Window & { capInputBatches?: unknown[] }).capInputBatches = [];
87+
Object.defineProperty(window, "chrome", {
88+
configurable: true,
89+
value: {
90+
runtime: {
91+
lastError: null,
92+
onMessage: {
93+
addListener: (listener: ContentListener) => {
94+
(
95+
window as Window & {
96+
capInputListener?: ContentListener;
97+
}
98+
).capInputListener = listener;
99+
},
100+
},
101+
sendMessage: (
102+
message: unknown,
103+
callback: (value: unknown) => void,
104+
) => {
105+
(
106+
window as Window & { capInputBatches?: unknown[] }
107+
).capInputBatches?.push(message);
108+
callback({ ok: true });
109+
},
110+
},
111+
storage: {
112+
onChanged: { addListener: () => undefined },
113+
},
114+
},
115+
});
116+
});
117+
await page.goto(`http://127.0.0.1:${server.port}/capture.html`);
118+
await page.addScriptTag({ content: contentScript });
119+
assert.deepEqual(pageErrors, []);
120+
assert.equal(
121+
await page.evaluate(
122+
() =>
123+
typeof (
124+
window as Window & {
125+
capInputListener?: (...args: unknown[]) => unknown;
126+
}
127+
).capInputListener,
128+
),
129+
"function",
130+
);
131+
const requestCapture = (type: string) =>
132+
page.evaluate((requestType) => {
133+
const listener = (
134+
window as Window & {
135+
capInputListener?: (
136+
message: { type: string; recordingId: string },
137+
sender: unknown,
138+
respond: (value: unknown) => void,
139+
) => boolean;
140+
}
141+
).capInputListener;
142+
if (!listener) throw new Error("Content listener did not load");
143+
return new Promise<unknown>((resolve) => {
144+
listener(
145+
{ type: requestType, recordingId: "privacy-replay" },
146+
{},
147+
resolve,
148+
);
149+
});
150+
}, type);
151+
assert.deepEqual(await requestCapture("input-capture-start"), { ok: true });
152+
153+
const privateSelectors = [
154+
"#password",
155+
"#editable",
156+
"#textbox",
157+
"#pin",
158+
"#secret",
159+
"#email-label",
160+
"#email",
161+
"#card",
162+
"#pay",
163+
"#shadow-host input",
164+
"#overlay-button",
165+
];
166+
const privateBounds = await Promise.all(
167+
privateSelectors.map(async (selector) => {
168+
const bounds = await page.locator(selector).boundingBox();
169+
assert.ok(bounds, selector);
170+
return bounds;
171+
}),
172+
);
173+
for (const selector of privateSelectors) await page.locator(selector).click();
174+
const publicBounds = await page.locator("#public").boundingBox();
175+
assert.ok(publicBounds);
176+
await page.locator("#public").click();
177+
const shippingBounds = await page.locator("#shipping").boundingBox();
178+
assert.ok(shippingBounds);
179+
await page.locator("#shipping").click();
180+
await page.keyboard.press("Escape");
181+
assert.deepEqual(await requestCapture("input-capture-stop"), { ok: true });
182+
183+
const rawBatches = await page.evaluate(
184+
() =>
185+
(window as Window & { capInputBatches?: unknown[] }).capInputBatches ??
186+
[],
187+
);
188+
const batches = rawBatches.map(parseCapturedTabInputBatch);
189+
assert.ok(batches.length > 0);
190+
assert.ok(batches.every((batch) => batch !== null));
191+
const pointerSamples = batches.flatMap(
192+
(batch) =>
193+
batch?.events.flatMap((event) =>
194+
event.kind === "move" || event.kind === "down" || event.kind === "up"
195+
? [
196+
{
197+
kind: event.kind,
198+
x: event.x * batch.viewportWidth,
199+
y: event.y * batch.viewportHeight,
200+
},
201+
]
202+
: [],
203+
) ?? [],
204+
);
205+
const inside = (
206+
point: { x: number; y: number },
207+
bounds: { x: number; y: number; width: number; height: number },
208+
) =>
209+
point.x >= bounds.x &&
210+
point.x <= bounds.x + bounds.width &&
211+
point.y >= bounds.y &&
212+
point.y <= bounds.y + bounds.height;
213+
assert.ok(
214+
pointerSamples.some(
215+
(point) => point.kind === "down" && inside(point, publicBounds),
216+
),
217+
);
218+
assert.ok(
219+
pointerSamples.some(
220+
(point) => point.kind === "down" && inside(point, shippingBounds),
221+
),
222+
);
223+
for (const bounds of privateBounds) {
224+
assert.equal(
225+
pointerSamples.some((point) => inside(point, bounds)),
226+
false,
227+
);
228+
}
229+
assert.ok(
230+
batches.some((batch) =>
231+
batch?.events.some(
232+
(event) => event.kind === "keyDown" && event.key === "Escape",
233+
),
234+
),
235+
);
236+
assert.deepEqual(pageErrors, []);
237+
process.stdout.write(
238+
`${JSON.stringify({ privateTargets: privateSelectors.length, publicPointerEvents: pointerSamples.length, batches: batches.length, pageErrors })}\n`,
239+
);
240+
} finally {
241+
await browser.close();
242+
server.stop(true);
243+
await rm(tempDirectory, { recursive: true, force: true });
244+
}

‎apps/chrome-extension/src/content/input-events.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const MAX_BATCH_EVENTS = 128;
1010
const MAX_PENDING_BATCHES = 8;
1111
const OVERLAY_ROOT_ID = "cap-extension-recorder-overlay";
1212
const EDITABLE_ROLES = new Set(["textbox", "searchbox", "combobox"]);
13+
const SENSITIVE_CONTROL_NAME =
14+
/\b(?:password|passcode|pin|otp|one[- ]time(?:[- ]code)?|cvv|cvc|credit card|card number|security code|secret)\b/i;
15+
const SENSITIVE_FORM_FIELDS = `input[type="password"], input[autocomplete*="cc-"], input[autocomplete*="one-time-code"], input[name*="password" i], input[name="pin" i], input[name$="-pin" i], input[name$="_pin" i], input[name*="cvv" i], input[name*="cvc" i]`;
1316
const ALLOWED_CURSORS = new Set([
1417
"auto",
1518
"default",
@@ -49,7 +52,7 @@ const modifiersOf = (event: MouseEvent | KeyboardEvent) => {
4952
return modifiers;
5053
};
5154

52-
const isEditableTarget = (event: KeyboardEvent) =>
55+
const isEditableTarget = (event: Event) =>
5356
event.composedPath().some((target) => {
5457
if (!(target instanceof HTMLElement)) return false;
5558
return (
@@ -62,6 +65,32 @@ const isEditableTarget = (event: KeyboardEvent) =>
6265
);
6366
});
6467

68+
const isSensitivePointerTarget = (event: PointerEvent) =>
69+
isEditableTarget(event) ||
70+
event.composedPath().some((target) => {
71+
if (!(target instanceof HTMLElement)) return false;
72+
if (
73+
target instanceof HTMLIFrameElement ||
74+
target.hasAttribute("data-private") ||
75+
target.hasAttribute("data-sensitive") ||
76+
SENSITIVE_CONTROL_NAME.test(
77+
`${target.getAttribute("aria-label") ?? ""} ${target.getAttribute("title") ?? ""} ${target.getAttribute("name") ?? ""}`,
78+
)
79+
)
80+
return true;
81+
if (target instanceof HTMLLabelElement && target.control) {
82+
return (
83+
target.control instanceof HTMLInputElement ||
84+
target.control instanceof HTMLTextAreaElement ||
85+
target.control instanceof HTMLSelectElement
86+
);
87+
}
88+
return (
89+
target instanceof HTMLFormElement &&
90+
target.querySelector(SENSITIVE_FORM_FIELDS) !== null
91+
);
92+
});
93+
6594
const isExtensionUiEvent = (event: Event) =>
6695
event
6796
.composedPath()
@@ -180,6 +209,7 @@ export function initTabInputCapture() {
180209
!recordingId ||
181210
!event.isTrusted ||
182211
isExtensionUiEvent(event) ||
212+
isSensitivePointerTarget(event) ||
183213
window.innerWidth <= 0 ||
184214
window.innerHeight <= 0
185215
)

0 commit comments

Comments
 (0)