fix(browser): retain excluded scope through final URL capture

Latch target scope exclusion so a late frame-tree reply cannot restore a stale approved final URL or complete coverage. Preserve redirect observations and approved controls.

Fixes #2593
This commit is contained in:
morluto
2026-10-14 14:46:37 +08:00
parent 410537667e
commit b98be5408b
2 changed files with 95 additions and 9 deletions
+23 -9
View File
@@ -106,6 +106,7 @@ export const observeCdpSession = async (
message: "Browser observation window ended",
terminal: true,
});
if (capture.scopeExcluded) endReason = "target_left_scope";
return {
browser: context.discovery.version,
target: {
@@ -139,6 +140,7 @@ class TimelineCapture {
finalUrl: string | null;
#sequence = 0;
#pendingReload = false;
#scopeExcluded = false;
#endReason: EndReason | undefined;
readonly #listeners = new Set<(reason: EndReason) => void>();
@@ -152,7 +154,7 @@ class TimelineCapture {
}
ingest(event: CdpEvent): void {
if (this.#endReason !== undefined) return;
if (this.#endReason !== undefined || this.#scopeExcluded) return;
const params = recordValue(event.params);
if (params === undefined) return;
switch (event.method) {
@@ -207,10 +209,24 @@ class TimelineCapture {
}
setFinalUrl(rawUrl: string): void {
if (this.#scopeExcluded) return;
const destination = scopedUrl(rawUrl, this.allowedOrigins);
this.finalUrl = destination.scope === "approved" ? destination.url : null;
}
get scopeExcluded(): boolean {
return this.#scopeExcluded;
}
excludeTargetScope(
reason: "invalid_protocol_value" | "out_of_target_scope",
): void {
if (this.#scopeExcluded) return;
this.#scopeExcluded = true;
this.finalUrl = null;
this.completeness.exclude("timeline", reason);
}
#navigationRequested(event: CdpEvent, params: UnknownRecord): void {
if (!isMainFrameNavigation(event, this.mainFrameId)) return;
const reason = safeDetail(params.reason);
@@ -239,8 +255,7 @@ class TimelineCapture {
this.#pendingReload = false;
if (destination.scope === "approved") this.finalUrl = destination.url;
else {
this.finalUrl = null;
this.completeness.exclude("timeline", "out_of_target_scope");
this.excludeTargetScope("out_of_target_scope");
this.end("target_left_scope");
}
}
@@ -258,8 +273,7 @@ class TimelineCapture {
});
if (destination.scope === "approved") this.finalUrl = destination.url;
else {
this.finalUrl = null;
this.completeness.exclude("timeline", "out_of_target_scope");
this.excludeTargetScope("out_of_target_scope");
this.end("target_left_scope");
}
}
@@ -283,7 +297,7 @@ class TimelineCapture {
detail: null,
});
if (destination.scope !== "approved") {
this.completeness.exclude("timeline", "out_of_target_scope");
this.excludeTargetScope("out_of_target_scope");
this.end("target_left_scope");
}
}
@@ -370,12 +384,14 @@ const captureFinalUrl = async (
capture: TimelineCapture,
allowedOrigins: ReadonlySet<string>,
): Promise<EndReason> => {
if (capture.scopeExcluded) return "target_left_scope";
const result = await context.connection.send(
"Page.getFrameTree",
{},
context.sessionId,
context.signal,
);
if (capture.scopeExcluded) return "target_left_scope";
const url = mainFrameUrl(result);
if (
url !== undefined &&
@@ -384,9 +400,7 @@ const captureFinalUrl = async (
capture.setFinalUrl(url);
return "window_elapsed";
}
capture.finalUrl = null;
capture.completeness.exclude(
"timeline",
capture.excludeTargetScope(
url === undefined ? "invalid_protocol_value" : "out_of_target_scope",
);
return "target_left_scope";
@@ -50,8 +50,80 @@ it.each(["Document", undefined] as const)(
);
if (!result.ok) throw result.error;
expect(result.value.window.end_reason).toBe("target_left_scope");
expect(result.value.target.final_url).toBeNull();
expect(result.value.timeline.some(({ type }) => type === "redirect")).toBe(
true,
);
},
);
it("keeps final URL unknown when a redirect races the final frame-tree read", async () => {
let frameTreeReads = 0;
const browser = await startFakeCdpBrowser({
eventsBeforeReply: ["Page.getFrameTree"],
commandEvents: (command) => {
if (command.method !== "Page.getFrameTree") return undefined;
frameTreeReads += 1;
if (frameTreeReads !== 2 || command.sessionId === undefined)
return undefined;
return [
{
method: "Network.requestWillBeSent",
sessionId: command.sessionId,
params: {
requestId: "late-main-document",
type: "Document",
frameId: "frame-main",
request: {
url: "https://private.example.test/redirected",
method: "GET",
},
redirectResponse: { status: 302 },
},
},
];
},
});
onTestFinished(async () => {
await browser.close();
});
const result = await new CdpBrowserProvider().observeSession(
observeWebSessionInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 1,
}),
);
if (!result.ok) throw result.error;
expect(result.value.window.end_reason).toBe("target_left_scope");
expect(result.value.target.final_url).toBeNull();
expect(result.value.timeline.at(-1)).toMatchObject({
type: "redirect",
url: null,
destination_scope: "outside_policy",
});
expect(JSON.stringify(result.value)).not.toContain("private.example.test");
});
it("retains the approved URL returned by the final frame-tree read", async () => {
const browser = await startFakeCdpBrowser();
onTestFinished(async () => {
await browser.close();
});
const result = await new CdpBrowserProvider().observeSession(
observeWebSessionInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 1,
}),
);
if (!result.ok) throw result.error;
expect(result.value.window.end_reason).toBe("window_elapsed");
expect(result.value.target.final_url).toBe(
`${browser.allowedOrigin}/app?token=frame-secret`,
);
});