fix(browser): resolve empty storage-world origins from exact frame evidence

This commit is contained in:
morluto
2026-10-24 02:55:45 +08:00
parent 612bbee947
commit c3dcc69c2d
3 changed files with 144 additions and 11 deletions
+5
View File
@@ -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,
+87 -2
View File
@@ -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);
+52 -9
View File
@@ -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>,