🐛 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-08-17 10:36:29 +00:00
parent b04aa1d585
commit d80cb5f2dc
3 changed files with 19 additions and 17 deletions

View File

@ -116,8 +116,8 @@ 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) {
// 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, "*");
```
-<code class="language-js">responseMessage</code> is the data you want to send back to Penpot.
-<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.
- 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

View File

@ -127,6 +127,7 @@ describe('plugin-loader', () => {
sendMessage: vi.fn(),
},
iframeWindow: mockIframeWindow,
manifest: { ...manifest, host: 'http://localhost:4202' },
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
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<ReturnType<typeof createPlugin>>;
const mockPluginApi2 = {
@ -176,6 +180,7 @@ describe('plugin-loader', () => {
sendMessage: vi.fn(),
},
iframeWindow: mockIframeWindow2,
manifest: { ...manifest, host: 'http://localhost:4203' },
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
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 () => {

View File

@ -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);