mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(browser): resolve empty storage-world origins from exact frame evidence
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<StorageFrameTree> = 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<string> {
|
||||
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<typeof pausedEvent>,
|
||||
|
||||
Reference in New Issue
Block a user