diff --git a/docs/plugins/create-a-plugin.md b/docs/plugins/create-a-plugin.md index 9a25d101b9..0a8f14a8fe 100644 --- a/docs/plugins/create-a-plugin.md +++ b/docs/plugins/create-a-plugin.md @@ -116,6 +116,10 @@ Your plugin can capture incoming messages from Penpot using the { + // Validate the source to ensure messages come from the parent (Penpot) + if (event.source !== window.parent) { + return; + } // Handle the incoming message console.log(event.data); }); @@ -129,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, targetOrigin); +parent.postMessage(responseMessage, "*"); ``` -responseMessage is the data you want to send back to Penpot. --targetOrigin should be the origin of the Penpot application to ensure messages are only sent to the intended recipient. You can use'*' to allow all. +- 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/create-plugin.ts b/plugins/libs/plugins-runtime/src/lib/create-plugin.ts index fd7bc6a0a8..fc4ca233f3 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-plugin.ts @@ -39,5 +39,8 @@ export async function createPlugin( plugin, manifest, compartment: sandbox, + get iframeWindow() { + return plugin.iframeWindow; + }, }; } 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 cff0f5e57a..810ebd54b3 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts @@ -120,15 +120,86 @@ describe('plugin-loader', () => { }); it('should handle messages sent to plugins', async () => { + const mockIframeWindow = { nodeType: 1 } as unknown as Window; + const mockPluginWithIframe = { + plugin: { + close: mockClose, + sendMessage: vi.fn(), + }, + iframeWindow: mockIframeWindow, + manifest: { ...manifest, host: 'http://localhost:4202' }, + } as unknown as Awaited>; + + vi.mocked(createPlugin).mockResolvedValue(mockPluginWithIframe); + await loadPlugin(manifest); - window.dispatchEvent(new MessageEvent('message', { data: 'test-message' })); + const event = new MessageEvent('message', { + data: 'test-message', + origin: 'http://localhost:4202', + }); + Object.defineProperty(event, 'source', { value: mockIframeWindow }); + window.dispatchEvent(event); - expect(mockPluginApi.plugin.sendMessage).toHaveBeenCalledWith( + expect(mockPluginWithIframe.plugin.sendMessage).toHaveBeenCalledWith( 'test-message', ); }); + it('should reject messages from unrecognized sources', async () => { + await loadPlugin(manifest); + + 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(); + }); + + it('should only route messages to the sender plugin', async () => { + const mockIframeWindow1 = { nodeType: 1 } as unknown as Window; + const mockIframeWindow2 = { nodeType: 2 } as unknown as Window; + + const mockPluginApi1 = { + plugin: { + close: vi.fn(), + sendMessage: vi.fn(), + }, + iframeWindow: mockIframeWindow1, + manifest: { ...manifest, host: 'http://localhost:4202' }, + } as unknown as Awaited>; + + const mockPluginApi2 = { + plugin: { + close: vi.fn(), + sendMessage: vi.fn(), + }, + iframeWindow: mockIframeWindow2, + manifest: { ...manifest, host: 'http://localhost:4203' }, + } as unknown as Awaited>; + + vi.mocked(createPlugin).mockResolvedValue(mockPluginApi1); + await loadPlugin(manifest); + + vi.mocked(createPlugin).mockResolvedValue(mockPluginApi2); + await loadPlugin(manifest); + + const event = new MessageEvent('message', { + data: 'test', + 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 () => { await ɵloadPlugin(manifest); diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts index 8a88f23050..d05178f1f7 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts @@ -30,8 +30,10 @@ const closeAllPlugins = () => { window.addEventListener('message', (event) => { try { - for (const it of plugins) { - it.plugin.sendMessage(event.data); + const senderPlugin = plugins.find((it) => it.iframeWindow === event.source); + + if (senderPlugin) { + senderPlugin.plugin.sendMessage(event.data); } } catch (err) { console.error(err); diff --git a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts index 6939b8097a..f943c510a1 100644 --- a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts +++ b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts @@ -18,6 +18,7 @@ export class PluginModalElement extends HTMLElement { wrapper = document.createElement('div'); #inner = document.createElement('div'); #dragEvents: ReturnType | null = null; + #iframe: HTMLIFrameElement | null = null; setTheme(theme: Theme) { if (this.wrapper) { @@ -110,15 +111,15 @@ export class PluginModalElement extends HTMLElement { header.appendChild(closeButton); - const iframe = document.createElement('iframe'); - iframe.src = iframeSrc; + this.#iframe = document.createElement('iframe'); + this.#iframe.src = iframeSrc; const allowList: string[] = []; if (allowClipboardRead) allowList.push('clipboard-read'); if (allowClipboardWrite) allowList.push('clipboard-write'); - iframe.allow = allowList.join('; '); + this.#iframe.allow = allowList.join('; '); - iframe.sandbox.add( + this.#iframe.sandbox.add( 'allow-scripts', 'allow-forms', 'allow-modals', @@ -129,10 +130,10 @@ export class PluginModalElement extends HTMLElement { ); if (allowDownloads) { - iframe.sandbox.add('allow-downloads'); + this.#iframe.sandbox.add('allow-downloads'); } - iframe.addEventListener('load', () => { + this.#iframe.addEventListener('load', () => { this.shadowRoot?.dispatchEvent( new CustomEvent('load', { composed: true, @@ -159,12 +160,12 @@ export class PluginModalElement extends HTMLElement { ); this.addEventListener('message', (e: Event) => { - if (!iframe.contentWindow) { + if (!this.#iframe?.contentWindow) { return; } try { - iframe.contentWindow.postMessage((e as CustomEvent).detail, '*'); + this.#iframe.contentWindow.postMessage((e as CustomEvent).detail, '*'); } catch (err) { console.error( 'plugin modal: failed to send message to iframe via postMessage.', @@ -177,7 +178,7 @@ export class PluginModalElement extends HTMLElement { this.wrapper.appendChild(this.#inner); this.#inner.appendChild(header); - this.#inner.appendChild(iframe); + this.#inner.appendChild(this.#iframe); const style = document.createElement('style'); style.textContent = modalCss; @@ -187,6 +188,10 @@ export class PluginModalElement extends HTMLElement { this.calculateZIndex(); } + getIframeContentWindow(): Window | null { + return this.#iframe?.contentWindow ?? null; + } + size() { const width = Number(this.wrapper.style.width.replace('px', '') || '300'); const height = Number(this.wrapper.style.height.replace('px', '') || '400'); diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts index 8b811f55eb..2a2b43e1c6 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts @@ -157,6 +157,9 @@ export async function createPluginManager( } }, getModal: () => modal, + get iframeWindow(): Window | null { + return modal?.getIframeContentWindow() ?? null; + }, registerListener, registerMessageCallback, sendMessage: (message: unknown) => {