* 🐛 Fix deep-harden of host plugin context on load
ses.harden(context) in loadPlugin deep-freezes every host-owned object
and function reachable through the context. The host keeps needing to
modify those across page navigation (listener wrappers, proxies), so a
later property augmentation (e.g. assigning toString) throws
'TypeError: Cannot assign to read only property toString' and kills the
MCP session (penpot/penpot#11001).
Pass the host context through untouched; sandbox isolation stays at the
compartment boundary (hardened sandbox-owned globals + ses.safeReturn).
Regression test: load-plugin-context.spec.ts (no ses mock).
AI-assisted-by: muse-spark-1.3
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
* 🐛 Add real SES bootstrap to host-context regression test
The previous load-plugin-context.spec.ts had no SES bootstrap, so it
failed on the original code with 'ReferenceError: harden is not defined'
instead of the intended freeze assertion, and passed on the fixed code
merely by avoiding ses.harden.
Now the spec bootstraps real SES (repairIntrinsics + hardenIntrinsics),
adds a control test proving real ses.harden deep-freezes host-owned
functions (Object.isFrozen === true, later toString assignment throws
TypeError - the #11001 crash signature), and keeps the regression test
asserting loadPlugin leaves host functions unfrozen and patchable.
AI-assisted-by: muse-spark-1.3
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
* 🐛 Add production-order hardening contrast evidence
Proves the initialization-ordering hazard behind #11001 (cf. #8636):
in production, index.ts runs repairIntrinsics only at module load while
hardenIntrinsics runs later in createSandbox. The original loadPlugin
called ses.harden(context) between those steps, freezing the shared
Function.prototype with plain data properties so later override taming
is skipped and any subsequent fn.toString assignment throws TypeError.
Kept in a separate spec file so the full SES bootstrap in
load-plugin-context.spec.ts cannot mask the ordering effect.
AI-assisted-by: muse-spark-1.3
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
* 🐛 Apply approved lint fix and CHANGELOG entry
Restores the two approved deliverables missing from the previous push:
the prefer-rest-params fix in load-plugin-harden-order.spec.ts
(replacing the deprecated arguments usage) and the plugins-runtime
CHANGELOG entry for the host-context harden fix (#11001).
AI-assisted-by: muse-spark-1.3
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
* 🐛 Remove deep-hardening of host plugin context on load
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
Co-authored-by: multica-agent <github@multica.ai>
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
* 🐛 Align CHANGELOG and context comment with reviewed evidence
AI-assisted-by: multica-agent
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
Co-authored-by: multica-agent <github@multica.ai>
* 🔥 Remove SES semantic tests from plugin regression coverage
Drop the tests that only verify SES library semantics rather than Penpot
application behavior:
- Delete load-plugin-harden-order.spec.ts (pure SES initialization-order
evidence, never calls loadPlugin).
- Remove the ses.harden control test and its SES bootstrap setup from
load-plugin-context.spec.ts.
- Remove the #8636 hardening-order contrast test and the now-unused ses
import from load-plugin-real-path.spec.ts.
Keep the application-level regression coverage: the real loadPlugin
initialization path, permission enforcement, host-context isolation and
safeReturn protection. No production code changes.
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
AI-assisted-by: Omen Alpha
---------
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* 🐛 Validate plugin UI URLs cannot target Penpot's own domain
The plugin UI iframe combines allow-scripts and allow-same-origin in
its sandbox. While necessary for plugins to use their own cookies and
storage, this creates a risk if a plugin's UI URL resolves to Penpot's
own origin, potentially allowing the iframe to escape sandbox isolation.
Add validateUIUrl() that checks the resolved URL against Penpot's
origin (from penpotPublicURI or location.origin) and throws if they
match. Called in openModal() after prepareUrl() resolves the URL.
Closes#11271
AI-assisted-by: qwen3.7-plus
* 💄 Fix prettier formatting in plugin-manager.spec.ts
Apply prettier formatting to fix format:check failure.
AI-assisted-by: qwen3.7-plus
* 🐛 Fix problem with penpot origin plugins
---------
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
* 🐛 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
* 🐛 Fix plugin modal drag and close interactions
Switch plugin modal dragging to pointer-capture semantics from the header so drag state remains stable when crossing iframe boundaries. Prevent drag start from close-button pointerdown and add regression tests for both non-draggable close-button interaction and close-event dispatch.
Signed-off-by: Marek Hrabe <marekhrabe@me.com>
* 📚 Update changelog for plugin modal drag fix
Document plugin modal drag and close-button interaction fixes in the unreleased changelog.
Signed-off-by: Marek Hrabe <marekhrabe@me.com>
* 🐛 Simplify plugin modal drag CSS selection rules
Keep user-select disabled at the modal wrapper level and keep touch-action scoped to the header drag handle to remove redundant declarations while preserving drag behavior.
Signed-off-by: Marek Hrabe <marekhrabe@me.com>
---------
Signed-off-by: Marek Hrabe <marekhrabe@me.com>
* 🐛 Handle plugin errors gracefully without crashing the UI
Plugin errors (like 'Set is not a constructor') were propagating to the
global error handler and showing the exception page. This fix:
- Uses a WeakMap to track plugin errors (works in SES hardened environment)
- Wraps setTimeout/setInterval handlers to mark errors and re-throw them
- Frontend global handler checks isPluginError and logs to console
Plugin errors are now logged to console with 'Plugin Error' prefix but
don't crash the main application or show the exception page.
Signed-off-by: AI Agent <agent@penpot.app>
* ✨ Improved handling of plugin errors on initialization
* ✨ Fix test and linter
---------
Signed-off-by: AI Agent <agent@penpot.app>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
- Change the default for the newWindow param from true to false, so
openPage() navigates in the same tab instead of opening a new one
- Accept a UUID string as the page argument in addition to a Page object,
avoiding the need to call penpot.getPage(uuid) first
- Add validation error when an invalid page argument is passed
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
- Change the default for the newWindow param from true to false, so
openPage() navigates in the same tab instead of opening a new one
- Accept a UUID string as the page argument in addition to a Page object,
avoiding the need to call penpot.getPage(uuid) first
- Add validation error when an invalid page argument is passed
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Fixes a crash where plugins sending messages via 'penpot.ui.sendMessage()'
could fail if their message payload contained non-serializable values like
functions or closures.
The fix adds validation using 'structuredClone()' to catch these messages
early with a helpful error message, and adds a defensive try/catch in the
modal's message handler as a safety net.
Fixes the error: 'Failed to execute postMessage on Window: ... could not
be cloned.'
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* ✨ Add core changes for mcp server
* ✨ Changes to plugins-runtime to add mcp extensions
* ✨ Changes to MCP plugin
* ✨ Changes post-review and ci fixes