fix(browser): close scenario containment gaps

This commit is contained in:
morluto 2026-07-24 14:40:07 +00:00
parent 83b3368d8d
commit 1bd65f68bc
6 changed files with 207 additions and 79 deletions

View File

@ -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;

View File

@ -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<string, unknown>[],
): 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<typeof captureStorage>[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];
})
: [];

View File

@ -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<string>;
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);
}

View File

@ -89,8 +89,9 @@ const installHttpPolicy = async (input: {
readonly scenario: BrowserScenario;
readonly secrets: BrowserScenarioSecrets;
readonly ownedPages: Set<Page>;
readonly claimOwnedPage: (page: Page) => Promise<boolean>;
}): Promise<void> => {
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<void> => {
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<boolean> => {
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,

View File

@ -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 = [

View File

@ -10,6 +10,7 @@ describe("PlaywrightScenarioEvents", () => {
it("bounds oversized page errors before validating captured events", () => {
const listeners = new Map<string, (value: Error) => 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);