diff --git a/src/browser/CdpObservationSession.ts b/src/browser/CdpObservationSession.ts index 694d2cd8c7..b256c4fa4f 100644 --- a/src/browser/CdpObservationSession.ts +++ b/src/browser/CdpObservationSession.ts @@ -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, ): Promise => { + 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"; diff --git a/tests/boundary/browser/cdpObservationResourceTypes.test.ts b/tests/boundary/browser/cdpObservationResourceTypes.test.ts index f7c97d3ddc..2dece6f965 100644 --- a/tests/boundary/browser/cdpObservationResourceTypes.test.ts +++ b/tests/boundary/browser/cdpObservationResourceTypes.test.ts @@ -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`, + ); +});