fix(browser): close scenario containment gaps
This commit is contained in:
parent
83b3368d8d
commit
1bd65f68bc
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
})
|
||||
: [];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue