Andrey Antukh 7e1139b906
🐛 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
2026-09-07 09:31:35 +02:00

204 lines
5.9 KiB
TypeScript

const closeSvg = `
<svg width="16" height="16"xmlns="http://www.w3.org/2000/svg" fill="none"><g class="fills"><rect rx="0" ry="0" width="16" height="16" class="frame-background"/></g><g class="frame-children"><path d="M11.997 3.997 8 8l-3.997 4.003m-.006-8L8 8l4.003 3.997" class="fills"/><g class="strokes"><path d="M11.997 3.997 8 8l-3.997 4.003m-.006-8L8 8l4.003 3.997" style="fill: none; stroke-width: 1; stroke: rgb(143, 157, 163); stroke-opacity: 1; stroke-linecap: round;" class="stroke-shape"/></g></g></svg>`;
import type { Theme } from '@penpot/plugin-types';
import { dragHandler } from '../drag-handler.js';
import modalCss from './plugin.modal.css?inline';
import { resizeModal } from '../create-modal.js';
const MIN_Z_INDEX = 300;
const Z_INDEX_VAR = '--z-index-set';
export class PluginModalElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
wrapper = document.createElement('div');
#inner = document.createElement('div');
#dragEvents: ReturnType<typeof dragHandler> | null = null;
#iframe: HTMLIFrameElement | null = null;
setTheme(theme: Theme) {
if (this.wrapper) {
this.wrapper.setAttribute('data-theme', theme);
}
}
resize(width: number, height: number) {
if (this.wrapper) {
resizeModal(this, width, height);
}
}
disconnectedCallback() {
this.#dragEvents?.();
}
calculateZIndex() {
const modals = document.querySelectorAll<HTMLElement>('plugin-modal');
const zIndexModals = Array.from(modals)
.filter((modal) => modal !== this)
.map((modal) => {
return Number(modal.style.zIndex);
});
// Read the application z-index scale via the inherited CSS custom property
// `--z-index-set` (defined on :root). Custom properties pierce shadow DOM
// boundaries, so the value is available even though the modal uses a Shadow
// root. Falls back to MIN_Z_INDEX when the variable is unset or unparseable
// (e.g. when the runtime is used outside the Penpot app shell).
const declared = getComputedStyle(this)
.getPropertyValue(Z_INDEX_VAR)
.trim();
const parsed = Number(declared);
const baseZIndex =
Number.isFinite(parsed) && parsed > 0 ? parsed : MIN_Z_INDEX;
const maxZIndex = Math.max(...zIndexModals, baseZIndex);
this.style.zIndex = (maxZIndex + 1).toString();
}
connectedCallback() {
const title = this.getAttribute('title');
const iframeSrc = this.getAttribute('iframe-src');
const allowDownloads = this.getAttribute('allow-downloads') || false;
const allowClipboardRead =
this.getAttribute('allow-clipboard-read') || false;
const allowClipboardWrite =
this.getAttribute('allow-clipboard-write') || false;
if (!title || !iframeSrc) {
throw new Error('title and iframe-src attributes are required');
}
if (!this.shadowRoot) {
throw new Error('Error creating shadow root');
}
this.#inner.classList.add('inner');
this.wrapper.classList.add('wrapper');
this.wrapper.style.maxInlineSize = '90vw';
this.wrapper.style.maxBlockSize = '90vh';
const header = document.createElement('div');
header.classList.add('header');
const h1 = document.createElement('h1');
h1.textContent = title;
header.appendChild(h1);
const closeButton = document.createElement('button');
closeButton.setAttribute('type', 'button');
closeButton.innerHTML = `<div class="close">${closeSvg}</div>`;
closeButton.addEventListener('click', () => {
if (!this.shadowRoot) {
return;
}
this.shadowRoot.dispatchEvent(
new CustomEvent('close', {
composed: true,
bubbles: true,
}),
);
});
header.appendChild(closeButton);
this.#iframe = document.createElement('iframe');
this.#iframe.src = iframeSrc;
const allowList: string[] = [];
if (allowClipboardRead) allowList.push('clipboard-read');
if (allowClipboardWrite) allowList.push('clipboard-write');
this.#iframe.allow = allowList.join('; ');
this.#iframe.sandbox.add(
'allow-scripts',
'allow-forms',
'allow-modals',
'allow-popups',
'allow-popups-to-escape-sandbox',
'allow-storage-access-by-user-activation',
'allow-same-origin',
);
if (allowDownloads) {
this.#iframe.sandbox.add('allow-downloads');
}
this.#iframe.addEventListener('load', () => {
this.shadowRoot?.dispatchEvent(
new CustomEvent('load', {
composed: true,
bubbles: true,
}),
);
});
// move modal to the top
this.#dragEvents = dragHandler(
header,
this.wrapper,
() => {
this.calculateZIndex();
},
{
start: () => {
this.wrapper.classList.add('is-dragging');
},
end: () => {
this.wrapper.classList.remove('is-dragging');
},
},
);
this.addEventListener('message', (e: Event) => {
if (!this.#iframe?.contentWindow) {
return;
}
try {
this.#iframe.contentWindow.postMessage((e as CustomEvent).detail, '*');
} catch (err) {
console.error(
'plugin modal: failed to send message to iframe via postMessage.',
err,
);
}
});
this.shadowRoot.appendChild(this.wrapper);
this.wrapper.appendChild(this.#inner);
this.#inner.appendChild(header);
this.#inner.appendChild(this.#iframe);
const style = document.createElement('style');
style.textContent = modalCss;
this.shadowRoot.appendChild(style);
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');
return { width, height };
}
}
customElements.define('plugin-modal', PluginModalElement);