From b5e879af4e45f537b76a81b3c3cc0ae81f3f1046 Mon Sep 17 00:00:00 2001 From: vibemasshq-dev Date: Wed, 2 Sep 2026 01:52:53 +0100 Subject: [PATCH] fix(client): secure postMessage communications and close iframe injection vector --- sandpack-client/src/clients/node/index.ts | 14 +++- .../inject-scripts/historyListener.test.ts | 74 +++++++++++++++++++ .../node/inject-scripts/historyListener.ts | 13 ++-- .../src/clients/node/inject-scripts/index.ts | 5 +- 4 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 sandpack-client/src/clients/node/inject-scripts/historyListener.test.ts diff --git a/sandpack-client/src/clients/node/index.ts b/sandpack-client/src/clients/node/index.ts index a12aadfd9..ebce4660e 100644 --- a/sandpack-client/src/clients/node/index.ts +++ b/sandpack-client/src/clients/node/index.ts @@ -251,7 +251,13 @@ export class SandpackNode extends SandpackClient { private async globalListeners(): Promise { window.addEventListener("message", (event) => { if (event.data.type === PREVIEW_LOADED_MESSAGE_TYPE) { - injectScriptToIframe(this.iframe, this.messageChannelId); + if ( + this.options.bundlerURL && + event.origin !== new URL(this.options.bundlerURL).origin + ) { + return; + } + injectScriptToIframe(this.iframe, this.messageChannelId, window.location.origin); } if ( @@ -435,9 +441,11 @@ export class SandpackNode extends SandpackClient { break; case "urlback": - case "urlforward": - this.iframe?.contentWindow?.postMessage(message, "*"); + case "urlforward": { + const targetOrigin = this.iframePreviewUrl ? new URL(this.iframePreviewUrl).origin : "*"; + this.iframe?.contentWindow?.postMessage(message, targetOrigin); break; + } case "shell/restart": this.restartShellProcess(); diff --git a/sandpack-client/src/clients/node/inject-scripts/historyListener.test.ts b/sandpack-client/src/clients/node/inject-scripts/historyListener.test.ts new file mode 100644 index 000000000..d04ab8d2c --- /dev/null +++ b/sandpack-client/src/clients/node/inject-scripts/historyListener.test.ts @@ -0,0 +1,74 @@ +/** + * @jest-environment jsdom + */ +import { setupHistoryListeners } from "./historyListener"; + +describe("historyListener", () => { + let mockPostMessage: jest.Mock; + let mockAddEventListener: jest.Mock; + let historyBackSpy: jest.SpyInstance; + + beforeEach(() => { + mockPostMessage = jest.fn(); + mockAddEventListener = jest.fn(); + + // Mock parent postMessage + (global as any).parent = { + postMessage: mockPostMessage, + }; + + // Mock window methods + window.addEventListener = mockAddEventListener; + historyBackSpy = jest.spyOn(window.history, "back").mockImplementation(() => {}); + + Object.defineProperty(window, "location", { + value: { + href: "https://sandbox.com", + reload: jest.fn(), + }, + writable: true + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should ignore messages from unauthorized origins", () => { + setupHistoryListeners({ + scope: { channelId: "123", parentOrigin: "https://trusted.com" }, + }); + + const historyGoSpy = jest.spyOn(window.history, "go").mockImplementation(() => {}); + + const handleMessage = mockAddEventListener.mock.calls.find( + (call) => call[0] === "message" + )[1]; + + handleMessage({ + data: { type: "urlback" }, + origin: "https://malicious.com", + }); + + expect(historyGoSpy).not.toHaveBeenCalled(); + }); + + it("should accept messages from authorized origin", () => { + setupHistoryListeners({ + scope: { channelId: "123", parentOrigin: "https://trusted.com" }, + }); + + const historyGoSpy = jest.spyOn(window.history, "go").mockImplementation(() => {}); + + const handleMessage = mockAddEventListener.mock.calls.find( + (call) => call[0] === "message" + )[1]; + + handleMessage({ + data: { type: "urlback" }, + origin: "https://trusted.com", + }); + + expect(historyGoSpy).toHaveBeenCalledWith(-1); + }); +}); diff --git a/sandpack-client/src/clients/node/inject-scripts/historyListener.ts b/sandpack-client/src/clients/node/inject-scripts/historyListener.ts index 142bd814a..6f0499548 100644 --- a/sandpack-client/src/clients/node/inject-scripts/historyListener.ts +++ b/sandpack-client/src/clients/node/inject-scripts/historyListener.ts @@ -3,7 +3,7 @@ export function setupHistoryListeners({ scope, }: { - scope: { channelId: string }; + scope: { channelId: string; parentOrigin: string }; }) { // @ts-ignore const origHistoryProto = window.history.__proto__; @@ -20,7 +20,7 @@ export function setupHistoryListeners({ forward: historyPosition < historyList.length - 1, channelId: scope.channelId, }, - "*" + scope.parentOrigin ); }; @@ -71,11 +71,14 @@ export function setupHistoryListeners({ interface NavigationMessage { type: "urlback" | "urlforward" | "refresh"; } - function handleMessage({ data }: { data: NavigationMessage }) { + function handleMessage(event: MessageEvent) { + const { data, origin } = event; + if (origin !== scope.parentOrigin) return; + if (data.type === "urlback") { - history.back(); + window.history.back(); } else if (data.type === "urlforward") { - history.forward(); + window.history.forward(); } else if (data.type === "refresh") { document.location.reload(); } diff --git a/sandpack-client/src/clients/node/inject-scripts/index.ts b/sandpack-client/src/clients/node/inject-scripts/index.ts index 86bb62cc5..1fcd696f5 100644 --- a/sandpack-client/src/clients/node/inject-scripts/index.ts +++ b/sandpack-client/src/clients/node/inject-scripts/index.ts @@ -21,14 +21,15 @@ const scripts = [ export const injectScriptToIframe = ( iframe: HTMLIFrameElement, - channelId: string + channelId: string, + parentOrigin: string ): void => { scripts.forEach(({ code, id }) => { const message: InjectMessage = { uid: id, type: INJECT_MESSAGE_TYPE, code: `exports.activate = ${code}`, - scope: { channelId }, + scope: { channelId, parentOrigin }, }; iframe.contentWindow?.postMessage(message, "*");