From f711a7826fd60c95636adacbcd72b7ccc0aee849 Mon Sep 17 00:00:00 2001 From: Pritesh Jain Date: Wed, 4 Nov 2026 08:20:03 +0530 Subject: [PATCH 1/2] fix(security): accept host events only from window.parent (MKT-22128) extensionEvent and uploadReady_ listeners had no window filter, so post-robot accepted them from any frame, including sibling app iframes. Scope both to window.parent, the window that holds the app iframe and the only sender in app-extension-component. The local post-robot typing gains the options argument that post-robot 8 already accepts. Co-Authored-By: Claude Opus 5.5 --- __test__/hostOnlyListeners.test.ts | 57 ++++++++++++++++++++++++++++++ src/stack/api/asset/index.ts | 1 + src/types/post-robot.d.ts | 6 +++- src/uiLocation.ts | 4 ++- 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 __test__/hostOnlyListeners.test.ts diff --git a/__test__/hostOnlyListeners.test.ts b/__test__/hostOnlyListeners.test.ts new file mode 100644 index 00000000..c964ce1b --- /dev/null +++ b/__test__/hostOnlyListeners.test.ts @@ -0,0 +1,57 @@ +import postRobot from "post-robot"; + +import Asset from "../src/stack/api/asset"; +import { IAppConfigInitData, LocationType } from "../src/types"; +import UiLocation from "../src/uiLocation"; + +jest.mock("post-robot"); + +// MKT-22128: the host (app-extension-component) sends extensionEvent and +// uploadReady_ from the window that holds the app iframe, i.e. +// window.parent. A listener without a window filter also accepts the same +// messages from sibling app frames, so both must be scoped to window.parent. +describe("host-only post-robot listeners (MKT-22128)", () => { + beforeEach(() => { + (postRobot as any).on.mockReturnValue({ cancel: jest.fn() }); + (postRobot as any).sendToParent.mockResolvedValue({ data: {} }); + }); + + afterEach(() => { + jest.clearAllMocks(); + window["postRobot"] = undefined; + }); + + it("listens for extensionEvent from window.parent only", () => { + new UiLocation({ + type: LocationType.APP_CONFIG_WIDGET, + app_id: "app_id", + installation_uid: "installation_uid", + extension_uid: "extension_uid", + region: "NA", + endpoints: { CMA: "", APP: "", DEVELOPER_HUB: "" }, + stack: {} as any, + user: {} as any, + currentBranch: "main", + manifest: {} as any, + } as IAppConfigInitData); + + const call = (postRobot as any).on.mock.calls.find( + ([name]) => name === "extensionEvent" + ); + expect(call).toBeDefined(); + // compare by identity: printing a Window on failure exhausts memory + expect(call[1]?.window === window.parent).toBe(true); + expect(typeof call[2]).toBe("function"); + }); + + it("listens for uploadReady from window.parent only", async () => { + await Asset({}).uploadAsset([new Blob(["x"])]); + + const call = (postRobot as any).on.mock.calls.find(([name]) => + String(name).startsWith("uploadReady_") + ); + expect(call).toBeDefined(); + expect(call[1]?.window === window.parent).toBe(true); + expect(typeof call[2]).toBe("function"); + }); +}); diff --git a/src/stack/api/asset/index.ts b/src/stack/api/asset/index.ts index 653b52bf..a0ce4c99 100755 --- a/src/stack/api/asset/index.ts +++ b/src/stack/api/asset/index.ts @@ -274,6 +274,7 @@ class Asset extends Base { try { const uploadReadyListener = postRobot.on( `uploadReady_${uid}`, + { window: window.parent }, function () { window.parent.postMessage( { diff --git a/src/types/post-robot.d.ts b/src/types/post-robot.d.ts index 00cbc6df..80770a29 100644 --- a/src/types/post-robot.d.ts +++ b/src/types/post-robot.d.ts @@ -23,7 +23,11 @@ declare module "post-robot" { cancel: () => void; } - export function on(name: string, handler?: HandlerType): CancelableType; + export function on( + name: string, + options?: ServerOptionsType | HandlerType, + handler?: HandlerType + ): CancelableType; export function once( name: string, diff --git a/src/uiLocation.ts b/src/uiLocation.ts index b84d68f7..bb27d01b 100755 --- a/src/uiLocation.ts +++ b/src/uiLocation.ts @@ -348,7 +348,9 @@ class UiLocation { } try { - postRobot.on("extensionEvent", async (event) => { + // Only the host (the window holding this iframe) sends events; ignore sibling frames. + const fromHost = { window: window.parent }; + postRobot.on("extensionEvent", fromHost, async (event) => { if (event.data.name === "entrySave") { emitter.emitEvent("entrySave", [{ data: event.data.data }]); emitter.emitEvent("updateFields", [ From b603720476ca182954c521fec375b7f56f6874e3 Mon Sep 17 00:00:00 2001 From: Pritesh Jain Date: Wed, 4 Nov 2026 08:39:44 +0530 Subject: [PATCH 2/2] test(security): assert listeners bind to a distinct parent window (MKT-22128) jsdom's window.parent is window, so the old assertion also passed with { window: window }. Stub the parent getter and assert against the stub. Co-Authored-By: Claude Opus 5.5 --- __test__/hostOnlyListeners.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/__test__/hostOnlyListeners.test.ts b/__test__/hostOnlyListeners.test.ts index c964ce1b..563bc30d 100644 --- a/__test__/hostOnlyListeners.test.ts +++ b/__test__/hostOnlyListeners.test.ts @@ -11,12 +11,18 @@ jest.mock("post-robot"); // window.parent. A listener without a window filter also accepts the same // messages from sibling app frames, so both must be scoped to window.parent. describe("host-only post-robot listeners (MKT-22128)", () => { + // jsdom's window.parent is window itself; a distinct parent proves the + // listener is bound to the parent and not to the app's own window. + const fakeParent = {} as Window; + beforeEach(() => { + jest.spyOn(window, "parent", "get").mockReturnValue(fakeParent); (postRobot as any).on.mockReturnValue({ cancel: jest.fn() }); (postRobot as any).sendToParent.mockResolvedValue({ data: {} }); }); afterEach(() => { + jest.restoreAllMocks(); jest.clearAllMocks(); window["postRobot"] = undefined; }); @@ -40,7 +46,7 @@ describe("host-only post-robot listeners (MKT-22128)", () => { ); expect(call).toBeDefined(); // compare by identity: printing a Window on failure exhausts memory - expect(call[1]?.window === window.parent).toBe(true); + expect(call[1]?.window === fakeParent).toBe(true); expect(typeof call[2]).toBe("function"); }); @@ -51,7 +57,7 @@ describe("host-only post-robot listeners (MKT-22128)", () => { String(name).startsWith("uploadReady_") ); expect(call).toBeDefined(); - expect(call[1]?.window === window.parent).toBe(true); + expect(call[1]?.window === fakeParent).toBe(true); expect(typeof call[2]).toBe("function"); }); });