From d80cb5f2dc7ce47b552b4aaab552d49ca8278c01 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 17 Aug 2026 10:36:29 +0000 Subject: [PATCH] :bug: Fix plugin origin check breaking cross-origin plugin messaging The origin check added in the previous commit compared event.origin against window.location.origin (Penpot own origin). Since plugins are cross-origin by design (hosted on the plugin author domain), this check rejected every legitimate message from every real plugin. The event.source-based sender routing (matching iframeWindow identity) is the correct and sufficient security mechanism - it cannot be forged cross-origin, so the redundant origin check was removed. - Removed event.origin check from load-plugin.ts message listener - Updated tests to use realistic plugin origins (localhost:4202/4203) and to verify rejection based on source identity, not origin - Fixed documentation examples: use event.source for receiving validation and '*' for postMessage targetOrigin AI-assisted-by: mimo-v2.5-pro --- docs/plugins/create-a-plugin.md | 8 +++---- .../src/lib/load-plugin.spec.ts | 24 ++++++++++++------- .../plugins-runtime/src/lib/load-plugin.ts | 4 ---- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/docs/plugins/create-a-plugin.md b/docs/plugins/create-a-plugin.md index 7ba79017b0..0a8f14a8fe 100644 --- a/docs/plugins/create-a-plugin.md +++ b/docs/plugins/create-a-plugin.md @@ -116,8 +116,8 @@ Your plugin can capture incoming messages from Penpot using the { - // Validate the origin to ensure messages come from a trusted source - if (event.origin !== window.location.origin) { + // Validate the source to ensure messages come from the parent (Penpot) + if (event.source !== window.parent) { return; } // Handle the incoming message @@ -133,11 +133,11 @@ This setup allows for two-way communication between Penpot and your plugin. Penp ```js // Sending a message back to Penpot from your plugin -parent.postMessage(responseMessage, window.location.origin); +parent.postMessage(responseMessage, "*"); ``` -responseMessage is the data you want to send back to Penpot. --window.location.origin should be used as the target origin to ensure messages are only sent to the intended recipient. Never use'*' in production, as it allows any origin to receive the message. +- Using'*' as the target origin is acceptable here because the message content is controlled by your plugin (the sender), not by untrusted input. If you know the exact Penpot origin, you can use it instead for stricter security. ### Summary diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts index 4621bdaa27..810ebd54b3 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts @@ -127,6 +127,7 @@ describe('plugin-loader', () => { sendMessage: vi.fn(), }, iframeWindow: mockIframeWindow, + manifest: { ...manifest, host: 'http://localhost:4202' }, } as unknown as Awaited>; vi.mocked(createPlugin).mockResolvedValue(mockPluginWithIframe); @@ -135,7 +136,7 @@ describe('plugin-loader', () => { const event = new MessageEvent('message', { data: 'test-message', - origin: window.location.origin, + origin: 'http://localhost:4202', }); Object.defineProperty(event, 'source', { value: mockIframeWindow }); window.dispatchEvent(event); @@ -145,15 +146,17 @@ describe('plugin-loader', () => { ); }); - it('should reject messages from unrecognized origins', async () => { + it('should reject messages from unrecognized sources', async () => { await loadPlugin(manifest); - window.dispatchEvent( - new MessageEvent('message', { - data: 'malicious-message', - origin: 'https://evil.com', - }), - ); + const event = new MessageEvent('message', { + data: 'malicious-message', + origin: 'https://evil.com', + }); + Object.defineProperty(event, 'source', { + value: { nodeType: 999 } as unknown as Window, + }); + window.dispatchEvent(event); expect(mockPluginApi.plugin.sendMessage).not.toHaveBeenCalled(); }); @@ -168,6 +171,7 @@ describe('plugin-loader', () => { sendMessage: vi.fn(), }, iframeWindow: mockIframeWindow1, + manifest: { ...manifest, host: 'http://localhost:4202' }, } as unknown as Awaited>; const mockPluginApi2 = { @@ -176,6 +180,7 @@ describe('plugin-loader', () => { sendMessage: vi.fn(), }, iframeWindow: mockIframeWindow2, + manifest: { ...manifest, host: 'http://localhost:4203' }, } as unknown as Awaited>; vi.mocked(createPlugin).mockResolvedValue(mockPluginApi1); @@ -186,12 +191,13 @@ describe('plugin-loader', () => { const event = new MessageEvent('message', { data: 'test', - origin: window.location.origin, + origin: 'http://localhost:4203', }); Object.defineProperty(event, 'source', { value: mockIframeWindow2 }); window.dispatchEvent(event); expect(mockPluginApi2.plugin.sendMessage).toHaveBeenCalledWith('test'); + expect(mockPluginApi1.plugin.sendMessage).not.toHaveBeenCalled(); }); it('should load plugin using ɵloadPlugin', async () => { diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts index 00c153859a..d05178f1f7 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts @@ -29,10 +29,6 @@ const closeAllPlugins = () => { }; window.addEventListener('message', (event) => { - if (event.origin !== window.location.origin) { - return; - } - try { const senderPlugin = plugins.find((it) => it.iframeWindow === event.source);