🐛 Validate origin and route messages to sender in plugin postMessage channel (#10970)

* 🐛 Fix plugin postMessage channel allowing cross-plugin message injection

The global postMessage listener was broadcasting incoming messages to all
loaded plugins without validating the origin or routing to the correct
sender. This allowed any plugin (or any iframe from any origin) to inject
messages into other plugins.

- Added origin validation — messages from origins other than
  window.location.origin are rejected.
- Added sender-based routing — a message is only delivered to the plugin
  whose iframe contentWindow matches event.source.
- Exposed iframeWindow getters in PluginManager, PluginModalElement, and
  createPlugin so the runtime can compare event.source against the correct
  iframe reference.
- Updated documentation examples to include origin validation and
  recommend window.location.origin over '*' for postMessage targetOrigin.

AI-assisted-by: qwen3.7-plus

* 🐛 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
This commit is contained in:
Andrey Antukh 2026-09-07 09:31:35 +02:00 committed by GitHub
parent 960209f1fa
commit 7e1139b906
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 103 additions and 15 deletions

View File

@ -116,6 +116,10 @@ Your plugin can capture incoming messages from Penpot using the <code class="lan
```js ```js
window.addEventListener("message", (event) => { window.addEventListener("message", (event) => {
// Validate the source to ensure messages come from the parent (Penpot)
if (event.source !== window.parent) {
return;
}
// Handle the incoming message // Handle the incoming message
console.log(event.data); console.log(event.data);
}); });
@ -129,11 +133,11 @@ This setup allows for two-way communication between Penpot and your plugin. Penp
```js ```js
// Sending a message back to Penpot from your plugin // Sending a message back to Penpot from your plugin
parent.postMessage(responseMessage, targetOrigin); parent.postMessage(responseMessage, "*");
``` ```
-<code class="language-js">responseMessage</code> is the data you want to send back to Penpot. -<code class="language-js">responseMessage</code> is the data you want to send back to Penpot.
-<code class="language-js">targetOrigin</code> should be the origin of the Penpot application to ensure messages are only sent to the intended recipient. You can use<code class="language-js">'*'</code> to allow all. - Using<code class="language-js">'*'</code> 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 ### Summary

View File

@ -39,5 +39,8 @@ export async function createPlugin(
plugin, plugin,
manifest, manifest,
compartment: sandbox, compartment: sandbox,
get iframeWindow() {
return plugin.iframeWindow;
},
}; };
} }

View File

@ -120,15 +120,86 @@ describe('plugin-loader', () => {
}); });
it('should handle messages sent to plugins', async () => { 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<ReturnType<typeof createPlugin>>;
vi.mocked(createPlugin).mockResolvedValue(mockPluginWithIframe);
await loadPlugin(manifest); 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', '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<ReturnType<typeof createPlugin>>;
const mockPluginApi2 = {
plugin: {
close: vi.fn(),
sendMessage: vi.fn(),
},
iframeWindow: mockIframeWindow2,
manifest: { ...manifest, host: 'http://localhost:4203' },
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
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 () => { it('should load plugin using ɵloadPlugin', async () => {
await ɵloadPlugin(manifest); await ɵloadPlugin(manifest);

View File

@ -30,8 +30,10 @@ const closeAllPlugins = () => {
window.addEventListener('message', (event) => { window.addEventListener('message', (event) => {
try { try {
for (const it of plugins) { const senderPlugin = plugins.find((it) => it.iframeWindow === event.source);
it.plugin.sendMessage(event.data);
if (senderPlugin) {
senderPlugin.plugin.sendMessage(event.data);
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@ -18,6 +18,7 @@ export class PluginModalElement extends HTMLElement {
wrapper = document.createElement('div'); wrapper = document.createElement('div');
#inner = document.createElement('div'); #inner = document.createElement('div');
#dragEvents: ReturnType<typeof dragHandler> | null = null; #dragEvents: ReturnType<typeof dragHandler> | null = null;
#iframe: HTMLIFrameElement | null = null;
setTheme(theme: Theme) { setTheme(theme: Theme) {
if (this.wrapper) { if (this.wrapper) {
@ -110,15 +111,15 @@ export class PluginModalElement extends HTMLElement {
header.appendChild(closeButton); header.appendChild(closeButton);
const iframe = document.createElement('iframe'); this.#iframe = document.createElement('iframe');
iframe.src = iframeSrc; this.#iframe.src = iframeSrc;
const allowList: string[] = []; const allowList: string[] = [];
if (allowClipboardRead) allowList.push('clipboard-read'); if (allowClipboardRead) allowList.push('clipboard-read');
if (allowClipboardWrite) allowList.push('clipboard-write'); 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-scripts',
'allow-forms', 'allow-forms',
'allow-modals', 'allow-modals',
@ -129,10 +130,10 @@ export class PluginModalElement extends HTMLElement {
); );
if (allowDownloads) { if (allowDownloads) {
iframe.sandbox.add('allow-downloads'); this.#iframe.sandbox.add('allow-downloads');
} }
iframe.addEventListener('load', () => { this.#iframe.addEventListener('load', () => {
this.shadowRoot?.dispatchEvent( this.shadowRoot?.dispatchEvent(
new CustomEvent('load', { new CustomEvent('load', {
composed: true, composed: true,
@ -159,12 +160,12 @@ export class PluginModalElement extends HTMLElement {
); );
this.addEventListener('message', (e: Event) => { this.addEventListener('message', (e: Event) => {
if (!iframe.contentWindow) { if (!this.#iframe?.contentWindow) {
return; return;
} }
try { try {
iframe.contentWindow.postMessage((e as CustomEvent).detail, '*'); this.#iframe.contentWindow.postMessage((e as CustomEvent).detail, '*');
} catch (err) { } catch (err) {
console.error( console.error(
'plugin modal: failed to send message to iframe via postMessage.', '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.wrapper.appendChild(this.#inner);
this.#inner.appendChild(header); this.#inner.appendChild(header);
this.#inner.appendChild(iframe); this.#inner.appendChild(this.#iframe);
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = modalCss; style.textContent = modalCss;
@ -187,6 +188,10 @@ export class PluginModalElement extends HTMLElement {
this.calculateZIndex(); this.calculateZIndex();
} }
getIframeContentWindow(): Window | null {
return this.#iframe?.contentWindow ?? null;
}
size() { size() {
const width = Number(this.wrapper.style.width.replace('px', '') || '300'); const width = Number(this.wrapper.style.width.replace('px', '') || '300');
const height = Number(this.wrapper.style.height.replace('px', '') || '400'); const height = Number(this.wrapper.style.height.replace('px', '') || '400');

View File

@ -157,6 +157,9 @@ export async function createPluginManager(
} }
}, },
getModal: () => modal, getModal: () => modal,
get iframeWindow(): Window | null {
return modal?.getIframeContentWindow() ?? null;
},
registerListener, registerListener,
registerMessageCallback, registerMessageCallback,
sendMessage: (message: unknown) => { sendMessage: (message: unknown) => {