makesomethingshit efa2518fe8
🐛 Fix deep-harden of host plugin context on load (#11521)
* 🐛 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>
2026-09-08 12:32:45 +02:00

110 lines
3.0 KiB
TypeScript

import type { Context } from '@penpot/plugin-types';
import { loadManifest } from './parse-manifest.js';
import { Manifest } from './models/manifest.model.js';
import { createPlugin } from './create-plugin.js';
let plugins: Awaited<ReturnType<typeof createPlugin>>[] = [];
export type ContextBuilder = (id: string) => Context;
let contextBuilder: ContextBuilder | null = null;
export function setContextBuilder(builder: ContextBuilder) {
contextBuilder = builder;
}
export const getPlugins = () => plugins;
const closeAllPlugins = () => {
plugins.forEach((pluginApi) => {
/* eslint-disable @typescript-eslint/no-explicit-any */
if (!(pluginApi.manifest as any)?.allowBackground) {
pluginApi.plugin.close();
}
});
plugins = [];
};
window.addEventListener('message', (event) => {
try {
const senderPlugin = plugins.find((it) => it.iframeWindow === event.source);
if (senderPlugin) {
senderPlugin.plugin.sendMessage(event.data);
}
} catch (err) {
console.error(err);
}
});
export const loadPlugin = async function (
manifest: Manifest,
closeCallback?: () => void,
apiExtensions?: object,
) {
try {
const context = contextBuilder && contextBuilder(manifest.pluginId);
if (!context) {
return;
}
closeAllPlugins();
// The host context is not deeply frozen at this load stage.
//
// The context still contains host-internal function objects and shared
// prototypes that the host may legitimately extend after plugin load
// (for example, by assigning custom properties). Deep-freezing here
// would freeze those prototypes before SES override taming completes,
// preventing later host-side mutations with a "Cannot assign to read
// only property" TypeError.
//
// Responsibility boundary: this function forwards the context to the
// sandbox layer without deep-freezing it. The public API that plugins
// consume is constructed by the API module (`api/index.ts`), and
// `createSandbox`'s proxy handler applies `ses.safeReturn` to values
// crossing into the sandbox. Compartment isolation and intrinsics
// hardening are performed by createSandbox, not here.
const plugin = await createPlugin(
context,
manifest,
() => {
plugins = plugins.filter((api) => api !== plugin);
if (closeCallback) {
closeCallback();
}
},
apiExtensions,
);
plugins.push(plugin);
} catch (error) {
closeAllPlugins();
throw error;
}
};
export const ɵloadPlugin = async function (
manifest: Manifest,
closeCallback?: () => void,
apiExtensions?: object,
) {
await loadPlugin(manifest, closeCallback, apiExtensions);
};
export const ɵloadPluginByUrl = async function (manifestUrl: string) {
const manifest = await loadManifest(manifestUrl);
await ɵloadPlugin(manifest);
};
export const ɵunloadPlugin = function (id: Manifest['pluginId']) {
const plugin = plugins.find((plugin) => plugin.manifest.pluginId === id);
if (plugin) {
plugin.plugin.close();
}
};