mirror of
https://github.com/penpot/penpot.git
synced 2026-08-06 12:58:55 +00:00
🐛 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
This commit is contained in:
parent
319a2185c9
commit
8e0f1635c1
@ -116,6 +116,10 @@ Your plugin can capture incoming messages from Penpot using the <code class="lan
|
||||
|
||||
```js
|
||||
window.addEventListener("message", (event) => {
|
||||
// Validate the origin to ensure messages come from a trusted source
|
||||
if (event.origin !== window.location.origin) {
|
||||
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, window.location.origin);
|
||||
```
|
||||
|
||||
-<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.
|
||||
-<code class="language-js">window.location.origin</code> should be used as the target origin to ensure messages are only sent to the intended recipient. Never use<code class="language-js">'*'</code> in production, as it allows any origin to receive the message.
|
||||
|
||||
### Summary
|
||||
|
||||
|
||||
@ -39,5 +39,8 @@ export async function createPlugin(
|
||||
plugin,
|
||||
manifest,
|
||||
compartment: sandbox,
|
||||
get iframeWindow() {
|
||||
return plugin.iframeWindow;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -120,15 +120,80 @@ 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,
|
||||
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
|
||||
|
||||
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: window.location.origin,
|
||||
});
|
||||
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 origins', async () => {
|
||||
await loadPlugin(manifest);
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: 'malicious-message',
|
||||
origin: 'https://evil.com',
|
||||
}),
|
||||
);
|
||||
|
||||
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,
|
||||
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
|
||||
|
||||
const mockPluginApi2 = {
|
||||
plugin: {
|
||||
close: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
},
|
||||
iframeWindow: mockIframeWindow2,
|
||||
} 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: window.location.origin,
|
||||
});
|
||||
Object.defineProperty(event, 'source', { value: mockIframeWindow2 });
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(mockPluginApi2.plugin.sendMessage).toHaveBeenCalledWith('test');
|
||||
});
|
||||
|
||||
it('should load plugin using ɵloadPlugin', async () => {
|
||||
await ɵloadPlugin(manifest);
|
||||
|
||||
|
||||
@ -29,9 +29,15 @@ const closeAllPlugins = () => {
|
||||
};
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.origin !== window.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@ -17,6 +17,7 @@ export class PluginModalElement extends HTMLElement {
|
||||
wrapper = document.createElement('div');
|
||||
#inner = document.createElement('div');
|
||||
#dragEvents: ReturnType<typeof dragHandler> | null = null;
|
||||
#iframe: HTMLIFrameElement | null = null;
|
||||
|
||||
setTheme(theme: Theme) {
|
||||
if (this.wrapper) {
|
||||
@ -97,15 +98,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',
|
||||
@ -116,10 +117,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,
|
||||
@ -146,12 +147,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.',
|
||||
@ -164,7 +165,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;
|
||||
@ -174,6 +175,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');
|
||||
|
||||
@ -157,6 +157,9 @@ export async function createPluginManager(
|
||||
}
|
||||
},
|
||||
getModal: () => modal,
|
||||
get iframeWindow(): Window | null {
|
||||
return modal?.getIframeContentWindow() ?? null;
|
||||
},
|
||||
registerListener,
|
||||
registerMessageCallback,
|
||||
sendMessage: (message: unknown) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user