mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
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:
@@ -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`,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user