From c3dcc69c2d48ea0245a54d3fbe37244f710c7742 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:55:45 +0800 Subject: [PATCH] fix(browser): resolve empty storage-world origins from exact frame evidence --- docs/browser-scenario-contract.md | 5 ++ src/browser/PlaywrightScenarioStorage.test.ts | 89 ++++++++++++++++++- src/browser/PlaywrightScenarioStorage.ts | 61 +++++++++++-- 3 files changed, 144 insertions(+), 11 deletions(-) diff --git a/docs/browser-scenario-contract.md b/docs/browser-scenario-contract.md index 9278f491b7..92d5dfc1de 100644 --- a/docs/browser-scenario-contract.md +++ b/docs/browser-scenario-contract.md @@ -226,6 +226,11 @@ local storage seeds even when they share an origin. The pre-script mechanism uses CDP debugger instrumentation. The observer resumes only pauses whose top frame belongs to its private seed script; page breakpoints and `debugger` statements remain under the attached debugger client's control. +If Chromium leaves the private isolated world's origin empty, REA looks up +that exact frame's reported security origin before selecting a seed. It does +not infer the origin from a frame URL or serialized storage key. Unresolved +frame identity or security origin fails initialization rather than silently +running with an unknown initial state. The result is Evidence with an initial state followed by one record per declared action. Each step reports action status, elapsed time, sanitized URLs, diff --git a/src/browser/PlaywrightScenarioStorage.test.ts b/src/browser/PlaywrightScenarioStorage.test.ts index 68f558a982..ec949474cd 100644 --- a/src/browser/PlaywrightScenarioStorage.test.ts +++ b/src/browser/PlaywrightScenarioStorage.test.ts @@ -9,6 +9,9 @@ import { PlaywrightScenarioStorage } from "./PlaywrightScenarioStorage.js"; class FakeCdp extends EventEmitter { readonly calls: { readonly method: string; readonly params?: unknown }[] = []; storageKey = "https://storage.example.test"; + frameTree: unknown = { + frame: { id: "root-frame", securityOrigin: "https://storage.example.test" }, + }; failDetachCount = 0; holdDetach = false; resolveDetach: (() => void) | undefined; @@ -55,6 +58,7 @@ class FakeCdp extends EventEmitter { return { identifier: "script" }; if (method === "Storage.getStorageKey") return { storageKey: this.storageKey }; + if (method === "Page.getFrameTree") return { frameTree: this.frameTree }; if (method === "Debugger.evaluateOnCallFrame") return {}; return {}; } @@ -163,7 +167,10 @@ const createInitializedStorage = async ( return owner; }; -const emitSeedPause = (cdp: FakeCdp): void => { +const emitSeedPause = ( + cdp: FakeCdp, + origin = "https://storage.example.test", +): void => { const addScript = cdp.calls.find( ({ method }) => method === "Page.addScriptToEvaluateOnNewDocument", ); @@ -174,7 +181,7 @@ const emitSeedPause = (cdp: FakeCdp): void => { cdp.emit("Runtime.executionContextCreated", { context: { id: 4, - origin: "https://storage.example.test", + origin, name: sourceUrl, auxData: { frameId: "root-frame" }, }, @@ -242,6 +249,84 @@ it("seeds a browser storage area once before the document script resumes", async await owner?.close(); }); +it("seeds an empty isolated-world origin using the exact frame security origin", async () => { + const cdp = new FakeCdp(); + cdp.frameTree = { + frame: { id: "other-frame", securityOrigin: "https://other.example.test" }, + childFrames: [ + { + frame: { + id: "root-frame", + url: "about:blank", + securityOrigin: "https://storage.example.test", + }, + }, + ], + }; + const { page, contextApi } = fixture(cdp); + const owner = await createInitializedStorage(contextApi, page); + emitSeedPause(cdp, ""); + await owner?.settle(); + expect( + cdp.calls.filter(({ method }) => method === "Debugger.evaluateOnCallFrame"), + ).toHaveLength(1); + expect( + cdp.calls.findIndex(({ method }) => method === "Page.getFrameTree"), + ).toBeLessThan( + cdp.calls.findIndex( + ({ method }) => method === "Debugger.evaluateOnCallFrame", + ), + ); + await owner?.close(); +}); + +it.each([ + { + frame: { + id: "other-frame", + securityOrigin: "https://storage.example.test", + }, + }, + { frame: { id: "root-frame", url: "https://storage.example.test/" } }, + { frame: { id: "root-frame", securityOrigin: "" } }, +])( + "refuses unknown seed applicability instead of inferring origin from a URL", + async (frameTree) => { + const cdp = new FakeCdp(); + cdp.frameTree = frameTree; + const { page, contextApi } = fixture(cdp); + const owner = await createInitializedStorage(contextApi, page); + emitSeedPause(cdp, ""); + await expect(owner?.settle()).rejects.toMatchObject({ + reason: "protocol_error", + }); + expect( + cdp.calls.filter( + ({ method }) => method === "Debugger.evaluateOnCallFrame", + ), + ).toHaveLength(0); + expect( + cdp.calls.filter(({ method }) => method === "Debugger.resume"), + ).toHaveLength(1); + await owner?.close(); + }, +); + +it("leaves a known unseeded frame origin unchanged", async () => { + const cdp = new FakeCdp(); + const { page, contextApi } = fixture(cdp); + const owner = await createInitializedStorage(contextApi, page); + emitSeedPause(cdp, "https://unseeded.example.test"); + await owner?.settle(); + expect(cdp.calls.some(({ method }) => method === "Page.getFrameTree")).toBe( + false, + ); + expect( + cdp.calls.some(({ method }) => method === "Debugger.evaluateOnCallFrame"), + ).toBe(false); + await owner?.close(); +}); + it("seeds session storage once per namespace and StorageKey", async () => { const cdp = new FakeCdp(); const { page, contextApi } = fixture(cdp); diff --git a/src/browser/PlaywrightScenarioStorage.ts b/src/browser/PlaywrightScenarioStorage.ts index 3a6fc45c48..9db6284a62 100644 --- a/src/browser/PlaywrightScenarioStorage.ts +++ b/src/browser/PlaywrightScenarioStorage.ts @@ -21,6 +21,20 @@ const storageKeyResponse = z.object({ storageKey: z.string() }); const targetInfoResponse = z.object({ targetInfo: z.object({ targetId: z.string() }), }); +interface StorageFrameTree { + readonly frame: { + readonly id: string; + readonly securityOrigin?: string | undefined; + }; + readonly childFrames?: StorageFrameTree[] | undefined; +} +const storageFrameTree: z.ZodType = z.lazy(() => + z.object({ + frame: z.object({ id: z.string(), securityOrigin: z.string().optional() }), + childFrames: z.array(storageFrameTree).optional(), + }), +); +const frameTreeResponse = z.object({ frameTree: storageFrameTree }); type StorageEventMethod = | "Runtime.executionContextCreated" | "Runtime.executionContextDestroyed" @@ -456,18 +470,25 @@ export class PlaywrightScenarioStorage { const contextId = target.scripts.get(frame.location.scriptId); const context = contextId === undefined ? undefined : target.contexts.get(contextId); - const seed = - context === undefined ? undefined : this.#seeds.get(context.origin); - if ( - this.#closed || - seed === undefined || - context?.auxData?.frameId === undefined - ) - return; + if (this.#closed) return; + const frameId = context?.auxData?.frameId; + if (context === undefined || frameId === undefined) + throw new Error( + "The private storage seed frame has no execution context or frame identity.", + ); + // Some Chrome builds leave an isolated world's origin empty. The frame's + // security origin is authoritative; neither its URL nor its opaque storage + // key establishes the document principal (for example, inherited origins). + const origin = + context.origin === "" + ? await this.frameOrigin(target, frameId) + : context.origin; + const seed = this.#seeds.get(origin); + if (seed === undefined) return; const storageKey = storageKeyResponse.parse( await this.sendObserved(target, "Storage.getStorageKey", { - frameId: context.auxData.frameId, + frameId, }), ).storageKey; const sessionKey = JSON.stringify([target.topLevelTargetId, storageKey]); @@ -502,6 +523,28 @@ export class PlaywrightScenarioStorage { if (session.length > 0) this.#initializedSession.add(sessionKey); } + private async frameOrigin( + target: StorageTarget, + frameId: string, + ): Promise { + const { frameTree } = frameTreeResponse.parse( + await this.sendObserved(target, "Page.getFrameTree"), + ); + const pending = [frameTree]; + while (pending.length > 0) { + const current = pending.pop()!; + if (current.frame.id === frameId) { + const origin = current.frame.securityOrigin; + if (origin !== undefined && origin !== "") return origin; + break; + } + pending.push(...(current.childFrames ?? [])); + } + throw new Error( + "The browser did not establish the private storage seed frame's security origin.", + ); + } + private async seed( target: StorageTarget, pause: z.infer,