From 1bd65f68bc632345b74a62bffe4394c84415a7f2 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:40:07 +0000 Subject: [PATCH] fix(browser): close scenario containment gaps --- src/browser/BrowserScenarioSecrets.ts | 7 +- src/browser/CdpCaptureStorage.ts | 39 ++--- src/browser/PlaywrightScenarioEvents.ts | 176 +++++++++++++++-------- src/browser/PlaywrightScenarioSession.ts | 51 ++++++- tests/browserScenario.test.ts | 11 ++ tests/playwrightScenarioEvents.test.ts | 2 + 6 files changed, 207 insertions(+), 79 deletions(-) diff --git a/src/browser/BrowserScenarioSecrets.ts b/src/browser/BrowserScenarioSecrets.ts index 873de2cc4..bbe07e69b 100644 --- a/src/browser/BrowserScenarioSecrets.ts +++ b/src/browser/BrowserScenarioSecrets.ts @@ -42,7 +42,12 @@ export class BrowserScenarioSecrets { redact(value: string): string { let output = value; - for (const [id, secret] of this.values) + const replacements = [...this.values].sort( + ([leftId, left], [rightId, right]) => + right.length - left.length || + (leftId < rightId ? -1 : leftId > rightId ? 1 : 0), + ); + for (const [id, secret] of replacements) if (secret !== "") output = output.replaceAll(secret, `${REDACTION_PREFIX}${id}]`); return output; diff --git a/src/browser/CdpCaptureStorage.ts b/src/browser/CdpCaptureStorage.ts index 7b3905a9b..d253035c2 100644 --- a/src/browser/CdpCaptureStorage.ts +++ b/src/browser/CdpCaptureStorage.ts @@ -63,13 +63,7 @@ export const captureStorage = async ( ) : undefined; const cacheValues = recordsValue(recordValue(cacheRaw)?.caches); - const caches = cacheValues.flatMap((cache) => { - const name = stringValue(cache.cacheName); - const id = stringValue(cache.cacheId); - return name === undefined || id === undefined - ? [] - : [{ name: name.slice(0, 1_024), id } satisfies CapturedCache]; - }); + const caches = capturedCaches(cacheValues); const maximum = context.input.limits.max_storage_keys; const fingerprints = context.input.include_storage_fingerprints ? await captureStorageFingerprints({ @@ -97,12 +91,18 @@ export const captureStorage = async ( origin, usage_bytes: numberValue(quota?.usage) ?? null, quota_bytes: numberValue(quota?.quota) ?? null, - local_storage_keys: local.items.map(({ key }) => key).slice(0, maximum), - session_storage_keys: session.items - .map(({ key }) => key) + local_storage_keys: local.items + .map(({ key }) => key.slice(0, 1_024)) + .slice(0, maximum), + session_storage_keys: session.items + .map(({ key }) => key.slice(0, 1_024)) + .slice(0, maximum), + indexed_db_names: indexed + .map((name) => name.slice(0, 1_024)) + .slice(0, maximum), + cache_names: caches + .map(({ name }) => name.slice(0, 1_024)) .slice(0, maximum), - indexed_db_names: indexed.slice(0, maximum), - cache_names: caches.map(({ name }) => name).slice(0, maximum), content_fingerprints: [...fingerprints.items], fingerprint_algorithm: "sha256", fingerprints_complete: @@ -118,6 +118,15 @@ const emptyStorageItems: CapturedStorageItems = { complete: false, }; +const capturedCaches = ( + values: readonly Record[], +): CapturedCache[] => + values.flatMap((cache) => { + const name = stringValue(cache.cacheName); + const id = stringValue(cache.cacheId); + return name === undefined || id === undefined ? [] : [{ name, id }]; + }); + const storageItems = async ( context: Parameters[0], origin: string, @@ -137,9 +146,7 @@ const storageItems = async ( if (!Array.isArray(entry)) return []; const key = stringValue(entry[0]); const value = stringValue(entry[1]); - return key === undefined || value === undefined - ? [] - : [{ key: key.slice(0, 1_024), value }]; + return key === undefined || value === undefined ? [] : [{ key, value }]; }); return { items, complete: items.length === entries.length }; }; @@ -148,6 +155,6 @@ const stringArray = (value: unknown): readonly string[] => Array.isArray(value) ? value.flatMap((item) => { const text = stringValue(item); - return text === undefined ? [] : [text.slice(0, 1_024)]; + return text === undefined ? [] : [text]; }) : []; diff --git a/src/browser/PlaywrightScenarioEvents.ts b/src/browser/PlaywrightScenarioEvents.ts index 2b3fd604e..9b1e1cea4 100644 --- a/src/browser/PlaywrightScenarioEvents.ts +++ b/src/browser/PlaywrightScenarioEvents.ts @@ -50,6 +50,7 @@ interface EventCaptureOptions { >; readonly secrets: BrowserScenarioSecrets; readonly budget: BrowserScenarioCaptureBudget; + readonly allowedOrigins: readonly string[]; } /** Bounded, arrival-ordered Playwright event capture with current-step attribution. */ @@ -81,12 +82,14 @@ export class PlaywrightScenarioEvents { private readonly limits: EventCaptureOptions["limits"]; private readonly secrets: BrowserScenarioSecrets; private readonly budget: BrowserScenarioCaptureBudget; + private readonly allowedOrigins: ReadonlySet; constructor(options: EventCaptureOptions) { this.enabled = options.enabled; this.limits = options.limits; this.secrets = options.secrets; this.budget = options.budget; + this.allowedOrigins = new Set(options.allowedOrigins); this.observePage(options.page); } @@ -160,63 +163,99 @@ export class PlaywrightScenarioEvents { private observePage(page: Page): void { if (this.enabled.has("console")) - page.on("console", (message) => this.console(message)); + page.on("console", (message) => { + if (this.pageInScope(page)) this.console(message); + }); if (this.enabled.has("page-errors")) - page.on("pageerror", (error) => - this.push({ - kind: "page-error", - message: this.secrets.redact(error.message).slice(0, 65_536), - stack: - error.stack === undefined - ? null - : this.secrets.redact(error.stack).slice(0, 262_144), - }), - ); + page.on("pageerror", (error) => { + if (this.pageInScope(page)) + this.push({ + kind: "page-error", + message: this.secrets.redact(error.message).slice(0, 65_536), + stack: + error.stack === undefined + ? null + : this.secrets.redact(error.stack).slice(0, 262_144), + }); + }); if (this.enabled.has("network")) { - page.on("request", (request) => this.request("request", request)); - page.on("response", (response) => this.response(response)); - page.on("requestfailed", (request) => - this.request("request-failed", request), - ); + page.on("request", (request) => { + if (this.pageInScope(page)) this.request("request", request); + }); + page.on("response", (response) => { + if (this.pageInScope(page)) this.response(response); + }); + page.on("requestfailed", (request) => { + if (this.pageInScope(page)) this.request("request-failed", request); + }); } if (this.enabled.has("websockets")) - page.on("websocket", (socket) => this.webSocket(socket)); + page.on("websocket", (socket) => { + if (this.pageInScope(page)) this.webSocket(page, socket); + }); if (this.enabled.has("frames")) { - page.on("frameattached", (frame) => this.frame("frame-attached", frame)); - page.on("framedetached", (frame) => this.frame("frame-detached", frame)); - page.on("framenavigated", (frame) => - this.frame("frame-navigated", frame), - ); + page.on("frameattached", (frame) => { + if (this.pageInScope(page)) this.frame("frame-attached", frame); + }); + page.on("framedetached", (frame) => { + if (this.pageInScope(page)) this.frame("frame-detached", frame); + }); + page.on("framenavigated", (frame) => { + if (this.pageInScope(page)) this.frame("frame-navigated", frame); + }); } if (this.enabled.has("workers")) - page.on("worker", (worker) => this.worker(worker)); + page.on("worker", (worker) => { + if (this.pageInScope(page)) this.worker(page, worker); + }); if (this.enabled.has("popups")) - page.on("popup", (popup) => this.popup(popup)); + page.on("popup", (popup) => { + if (this.pageInScope(page)) this.popup(popup); + }); if (this.enabled.has("downloads")) - page.on("download", (download) => this.download(download)); + page.on("download", (download) => { + if (this.pageInScope(page)) this.download(download); + }); + } + + private pageInScope(page: Page): boolean { + try { + return this.allowedOrigins.has(new URL(page.url()).origin); + } catch { + return false; + } + } + + private safeUrl(value: string) { + return sanitizeBrowserUrl(this.secrets.redact(value)); } private console(message: ConsoleMessage): void { const location = message.location(); this.push({ kind: "console", - level: message.type(), + level: this.secrets.redact(message.type()).slice(0, 64), text: this.secrets.redact(message.text()).slice(0, 65_536), - url: location.url === "" ? null : sanitizeBrowserUrl(location.url), + url: location.url === "" ? null : this.safeUrl(location.url), }); } private request(kind: "request" | "request-failed", request: Request): void { this.push({ kind, - method: request.method(), - url: sanitizeBrowserUrl(request.url()), - resource_type: request.resourceType(), + method: this.secrets.redact(request.method()).slice(0, 32), + url: this.safeUrl(request.url()), + resource_type: this.secrets.redact(request.resourceType()).slice(0, 64), status: null, - header_names: Object.keys(request.headers()).sort().slice(0, 256), + header_names: Object.keys(request.headers()) + .map((name) => this.secrets.redact(name).slice(0, 256)) + .sort() + .slice(0, 256), failure: kind === "request-failed" - ? (request.failure()?.errorText ?? "unknown") + ? this.secrets + .redact(request.failure()?.errorText ?? "unknown") + .slice(0, 1_024) : null, }); } @@ -225,26 +264,33 @@ export class PlaywrightScenarioEvents { const request = response.request(); this.push({ kind: "response", - method: request.method(), - url: sanitizeBrowserUrl(response.url()), - resource_type: request.resourceType(), + method: this.secrets.redact(request.method()).slice(0, 32), + url: this.safeUrl(response.url()), + resource_type: this.secrets.redact(request.resourceType()).slice(0, 64), status: response.status(), - header_names: Object.keys(response.headers()).sort().slice(0, 256), + header_names: Object.keys(response.headers()) + .map((name) => this.secrets.redact(name).slice(0, 256)) + .sort() + .slice(0, 256), failure: null, }); } - private webSocket(socket: WebSocket): void { + private webSocket(page: Page, socket: WebSocket): void { if (!this.admit("websockets", socket, this.limits.max_websockets)) return; - const url = sanitizeBrowserUrl(socket.url()); + const url = this.safeUrl(socket.url()); this.push({ kind: "websocket-opened", url }); - socket.on("framesent", ({ payload }) => - this.webSocketFrame("websocket-frame-sent", url, payload), - ); - socket.on("framereceived", ({ payload }) => - this.webSocketFrame("websocket-frame-received", url, payload), - ); - socket.on("close", () => this.push({ kind: "websocket-closed", url })); + socket.on("framesent", ({ payload }) => { + if (this.pageInScope(page)) + this.webSocketFrame("websocket-frame-sent", url, payload); + }); + socket.on("framereceived", ({ payload }) => { + if (this.pageInScope(page)) + this.webSocketFrame("websocket-frame-received", url, payload); + }); + socket.on("close", () => { + if (this.pageInScope(page)) this.push({ kind: "websocket-closed", url }); + }); } private webSocketFrame( @@ -272,34 +318,46 @@ export class PlaywrightScenarioEvents { if (!this.admit("frames", frame, this.limits.max_frames)) return; this.push({ kind, - url: frame.url() === "" ? null : sanitizeBrowserUrl(frame.url()), - name: frame.name() || null, + url: frame.url() === "" ? null : this.safeUrl(frame.url()), + name: + frame.name() === "" + ? null + : this.secrets.redact(frame.name()).slice(0, 1_024), }); } - private worker(worker: Worker): void { + private worker(page: Page, worker: Worker): void { if (!this.admit("workers", worker, this.limits.max_workers)) return; const details = { - url: sanitizeBrowserUrl(worker.url()), + url: this.safeUrl(worker.url()), name: null, }; this.push({ kind: "worker-created", ...details }); - worker.on("close", () => this.push({ kind: "worker-closed", ...details })); + worker.on("close", () => { + if (this.pageInScope(page)) + this.push({ kind: "worker-closed", ...details }); + }); } private popup(page: Page): void { + if (!this.pageInScope(page)) { + void page.close().catch(() => undefined); + return; + } if (!this.admit("popups", page, this.limits.max_popups)) return; const opened = { - url: page.url() === "" ? null : sanitizeBrowserUrl(page.url()), + url: page.url() === "" ? null : this.safeUrl(page.url()), name: null, }; this.push({ kind: "popup-opened", ...opened }); page.on("close", () => - this.push({ - kind: "popup-closed", - url: page.url() === "" ? null : sanitizeBrowserUrl(page.url()), - name: null, - }), + this.pageInScope(page) + ? this.push({ + kind: "popup-closed", + url: page.url() === "" ? null : this.safeUrl(page.url()), + name: null, + }) + : undefined, ); this.observePage(page); } @@ -307,8 +365,10 @@ export class PlaywrightScenarioEvents { private download(download: Download): void { this.push({ kind: "download-cancelled", - suggested_filename: download.suggestedFilename().slice(0, 1_024), - url: sanitizeBrowserUrl(download.url()), + suggested_filename: this.secrets + .redact(download.suggestedFilename()) + .slice(0, 1_024), + url: this.safeUrl(download.url()), }); void download.cancel().catch(() => undefined); } diff --git a/src/browser/PlaywrightScenarioSession.ts b/src/browser/PlaywrightScenarioSession.ts index af84358ca..a967f9619 100644 --- a/src/browser/PlaywrightScenarioSession.ts +++ b/src/browser/PlaywrightScenarioSession.ts @@ -89,8 +89,9 @@ const installHttpPolicy = async (input: { readonly scenario: BrowserScenario; readonly secrets: BrowserScenarioSecrets; readonly ownedPages: Set; + readonly claimOwnedPage: (page: Page) => Promise; }): Promise => { - const { context, scenario, secrets, ownedPages } = input; + const { context, scenario, secrets, ownedPages, claimOwnedPage } = input; const routes = scenario.request_replay.mode === "exact" ? new Map( @@ -107,6 +108,12 @@ const installHttpPolicy = async (input: { } catch { requestPage = undefined; } + if ( + scenario.browser.mode === "connect" && + requestPage !== undefined && + !ownedPages.has(requestPage) + ) + await claimOwnedPage(requestPage); if ( scenario.browser.mode === "connect" && (requestPage === undefined || !ownedPages.has(requestPage)) @@ -182,6 +189,25 @@ const installWebSocketPolicy = async ( }); }; +const blockAttachedServiceWorkers = async (page: Page): Promise => { + const controller = await page.evaluate( + "Boolean(navigator.serviceWorker?.controller)", + ); + if (controller) + throw new BrowserObservationError(OPERATION, "target_not_allowed"); + const blockRegistration = `(() => { + if (!("serviceWorker" in navigator)) return; + void navigator.serviceWorker.getRegistrations() + .then((registrations) => Promise.all(registrations.map((item) => item.unregister()))); + Object.defineProperty(ServiceWorkerContainer.prototype, "register", { + configurable: false, + value: () => Promise.reject(new Error("service workers are blocked by scenario policy")) + }); + })()`; + await page.addInitScript(blockRegistration); + await page.evaluate(blockRegistration); +}; + const initializePage = async ( context: BrowserContext, page: Page, @@ -191,17 +217,33 @@ const initializePage = async ( context.setDefaultTimeout(scenario.limits.action_timeout_ms); context.setDefaultNavigationTimeout(scenario.limits.navigation_timeout_ms); const ownedPages = new Set([page]); - await installHttpPolicy({ context, scenario, secrets, ownedPages }); + if (scenario.browser.mode === "connect") + await blockAttachedServiceWorkers(page); await installWebSocketPolicy( scenario.browser.mode === "launch" ? context : page, scenario, ); + const claimOwnedPage = async (candidate: Page): Promise => { + if (ownedPages.has(candidate)) return true; + const opener = await candidate.opener(); + if (opener === null || !ownedPages.has(opener)) return false; + await installWebSocketPolicy(candidate, scenario); + ownedPages.add(candidate); + return true; + }; + await installHttpPolicy({ + context, + scenario, + secrets, + ownedPages, + claimOwnedPage, + }); page.on("popup", (popup) => { - ownedPages.add(popup); if (scenario.browser.mode === "connect") - void installWebSocketPolicy(popup, scenario).catch(async () => { + void claimOwnedPage(popup).catch(async () => { await popup.close().catch(() => undefined); }); + else ownedPages.add(popup); }); await installStorageSeeds(context, page, scenario, secrets); return ownedPages; @@ -273,6 +315,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { limits: scenario.limits, secrets, budget: options.budget, + allowedOrigins: scenario.allowed_origins, }); const session = new PlaywrightScenarioSession( opened, diff --git a/tests/browserScenario.test.ts b/tests/browserScenario.test.ts index d24276917..1b12e9a02 100644 --- a/tests/browserScenario.test.ts +++ b/tests/browserScenario.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { BrowserScenarioSecrets } from "../src/browser/BrowserScenarioSecrets.js"; import { browserScenarioSchema } from "../src/domain/browserScenario.js"; const secret = (secret_id: string) => ({ source: "secret", secret_id }); @@ -159,6 +160,16 @@ describe("browserScenarioSchema", () => { ]); }); + it("redacts overlapping secrets longest-first", () => { + const scenario = browserScenarioSchema.parse(baseScenario()); + const secrets = BrowserScenarioSecrets.resolve(scenario, { + REA_TEST_SESSION: "prefix", + REA_TEST_PASSWORD: "prefix-suffix", + REA_TEST_COOKIE: "cookie", + }); + expect(secrets?.redact("prefix-suffix")).toBe("[REDACTED:login_input]"); + }); + it("rejects unsupported actions", () => { const scenario = baseScenario(); scenario.actions = [ diff --git a/tests/playwrightScenarioEvents.test.ts b/tests/playwrightScenarioEvents.test.ts index 58b31bb1e..82d9624e1 100644 --- a/tests/playwrightScenarioEvents.test.ts +++ b/tests/playwrightScenarioEvents.test.ts @@ -10,6 +10,7 @@ describe("PlaywrightScenarioEvents", () => { it("bounds oversized page errors before validating captured events", () => { const listeners = new Map void>(); const page = { + url: () => "https://app.example.test/", on: (name: string, listener: (value: Error) => void) => { listeners.set(name, listener); }, @@ -78,6 +79,7 @@ describe("PlaywrightScenarioEvents", () => { limits: scenario.limits, secrets, budget: new BrowserScenarioCaptureBudget(1, 1_000_000), + allowedOrigins: scenario.allowed_origins, }); const error = new Error("m".repeat(70_000)); error.stack = "s".repeat(300_000);