penpot/plugins/libs/plugins-runtime/src/lib/load-plugin-real-path.spec.ts
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

170 lines
6.1 KiB
TypeScript

import { describe, it, vi, expect, beforeAll } from 'vitest';
import 'ses';
import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin';
import type { Context } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js';
// Real initialization-path regression tests for #11001.
//
// NOTE: `./create-plugin`, `./plugin-manager` and
// `./create-sandbox` are intentionally NOT mocked here. This spec exercises
// the real `loadPlugin → createPlugin → createPluginManager → createSandbox`
// path with the real SES implementation, mirroring the production
// initialization order from `plugins-runtime/src/index.ts`:
// repairIntrinsics (module load) → loadPlugin → createSandbox/hardenIntrinsics
//
// `hardenIntrinsics()` is deliberately NOT called up front: the first test
// must run in the production window where only `repairIntrinsics` has run.
// Tests run in declaration order; the later tests build on the locked-down
// state the first real `loadPlugin` leaves behind (via `createSandbox`).
//
// Note on SES isolation: this suite depends on Vitest's default
// file-level isolation. Each test file runs in a separate worker
// process, so SES intrinsics frozen here do not leak into other
// spec files.
const REPAIR_OPTIONS = {
evalTaming: 'unsafeEval',
stackFiltering: 'verbose',
errorTaming: 'unsafe',
consoleTaming: 'unsafe',
errorTrapping: 'none',
unhandledRejectionTrapping: 'none',
};
function makeManifest(
code: string,
permissions: Manifest['permissions'],
): Manifest {
return {
pluginId: 'test-plugin',
name: 'Test Plugin',
host: '',
code,
permissions,
};
}
function makeHostFixture() {
const listenerTypes: string[] = [];
const listeners = new Map<symbol, string>();
// Inline code (empty host + non-URL code) resolves without network, so no
// fetch mock is needed. UI/modal APIs are never touched by the probe code.
const createRectangle = vi.fn(() => ({ type: 'rectangle-marker' }));
const selection: object[] = [{ id: 'shape-1' }];
const context = {
addListener: (type: string, _callback: (...args: unknown[]) => unknown) => {
const id = Symbol(type);
listeners.set(id, type);
listenerTypes.push(type);
return id;
},
removeListener: (id: symbol) => {
listeners.delete(id);
},
theme: 'dark',
createRectangle,
selection,
// Host-only member: present on the raw context but NOT part of the
// public penpot API. Plugin code must never see it (see B-2 below).
__internalSecret: 'host-internal',
} as unknown as Context;
return { context, listenerTypes, createRectangle, selection };
}
function lastCompartmentGlobalThis(): Record<string, unknown> {
const plugins = getPlugins();
const last = plugins[plugins.length - 1] as unknown as {
compartment: { compartment: { globalThis: Record<string, unknown> } };
};
return last.compartment.compartment.globalThis;
}
describe('loadPlugin real initialization path (regression for #11001)', () => {
beforeAll(() => {
// Production module-load step only: repairs intrinsics WITHOUT
// installing override taming, exactly like `index.ts` at import time.
(
globalThis as unknown as { repairIntrinsics(opts: object): void }
).repairIntrinsics({ ...REPAIR_OPTIONS });
});
it('loads through the real path and keeps host function augmentation working', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await loadPlugin(
makeManifest('penpot.on("finish", function () {});', ['content:read']),
);
// The plugin code really ran inside the sandbox: the manager registers
// `themechange` + `finish`, and the plugin code adds its own `finish`
// listener through the public API.
expect(fixture.listenerTypes).toEqual(['themechange', 'finish', 'finish']);
expect(getPlugins()).toHaveLength(1);
// The user-facing behavior from #11001: host-side augmentation of a
// fresh function (e.g. assigning `toString` during page navigation)
// succeeds after a real plugin load.
const freshWrapper = function freshWrapper() {
return 'navigation-wrapper';
};
expect(() => {
freshWrapper.toString = () => 'patched-by-runtime';
}).not.toThrow();
expect(fixture.listenerTypes.length).toBe(3);
});
it('denies the write API without permission and leaves the host untouched', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await expect(
loadPlugin(makeManifest('penpot.createRectangle();', ['content:read'])),
).rejects.toThrow(/content:write/);
expect(fixture.createRectangle).not.toHaveBeenCalled();
});
it('allows the same write API with permission', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await loadPlugin(
makeManifest('penpot.createRectangle();', [
'content:read',
'content:write',
]),
);
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
});
it('does not expose raw host-only context members to plugin code', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await loadPlugin(
makeManifest('globalThis.__probe = typeof penpot.__internalSecret;', [
'content:read',
]),
);
// The public `penpot` object is a boundary proxy over a curated API, not
// the raw host context, so host-only members are invisible inside.
expect(lastCompartmentGlobalThis()['__probe']).toBe('undefined');
});
it('keeps safeReturn protection on returned values without blocking allowed edits', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await loadPlugin(
makeManifest(
'penpot.createRectangle(); ' +
'globalThis.__selectionFrozen = Object.isFrozen(penpot.selection);',
['content:read', 'content:write'],
),
);
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
expect(lastCompartmentGlobalThis()['__selectionFrozen']).toBe(true);
});
});