mirror of
https://github.com/penpot/penpot.git
synced 2026-10-04 01:36:15 +00:00
* 🐛 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>
170 lines
6.1 KiB
TypeScript
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);
|
|
});
|
|
});
|