diff --git a/libs/plugins-runtime/src/lib/create-plugin.spec.ts b/libs/plugins-runtime/src/lib/create-plugin.spec.ts index b94d411..9c413a5 100644 --- a/libs/plugins-runtime/src/lib/create-plugin.spec.ts +++ b/libs/plugins-runtime/src/lib/create-plugin.spec.ts @@ -106,4 +106,14 @@ describe('createPlugin', () => { expect(mockSandbox.evaluate).toHaveBeenCalled(); }); + + it('should call plugin.close when there is an exception during sandbox evaluation', async () => { + vi.mocked(mockSandbox.evaluate).mockImplementation(() => { + throw new Error('Evaluation error'); + }); + + await createPlugin(mockContext, manifest, onCloseCallback); + + expect(mockPluginManager.close).toHaveBeenCalled(); + }); }); diff --git a/libs/plugins-runtime/src/lib/create-plugin.ts b/libs/plugins-runtime/src/lib/create-plugin.ts index 955a500..d126066 100644 --- a/libs/plugins-runtime/src/lib/create-plugin.ts +++ b/libs/plugins-runtime/src/lib/create-plugin.ts @@ -8,6 +8,16 @@ export async function createPlugin( manifest: Manifest, onCloseCallback: () => void ) { + const evaluateSandbox = async () => { + try { + sandbox.evaluate(); + } catch (error) { + console.error(error); + + plugin.close(); + } + }; + const plugin = await createPluginManager( context, manifest, @@ -16,12 +26,13 @@ export async function createPlugin( onCloseCallback(); }, function onReloadModal() { - sandbox.evaluate(); + evaluateSandbox(); } ); const sandbox = createSandbox(plugin); - sandbox.evaluate(); + + evaluateSandbox(); return { plugin, diff --git a/libs/plugins-runtime/src/lib/create-sandbox.spec.ts b/libs/plugins-runtime/src/lib/create-sandbox.spec.ts index a38fbf0..f2dcfc3 100644 --- a/libs/plugins-runtime/src/lib/create-sandbox.spec.ts +++ b/libs/plugins-runtime/src/lib/create-sandbox.spec.ts @@ -18,6 +18,7 @@ vi.mock('./ses.js', () => ({ }; }), harden: vi.fn().mockImplementation((obj) => obj), + safeReturn: vi.fn().mockImplementation((obj) => obj), }, })); @@ -90,22 +91,48 @@ describe('createSandbox', () => { expect(Object.keys(compartment.globalThis).length).toBe(0); }); - it('should ensure fetch requests omit credentials', async () => { + it('should ensure fetch requests omit credentials and return a harden response', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + url: 'https://example.com/api', + text: vi.fn().mockResolvedValue('response text'), + json: vi.fn().mockResolvedValue({ key: 'value' }), + }; + const sandbox = createSandbox(mockPlugin); const fetchSpy = vi .spyOn(window, 'fetch') - .mockResolvedValue(new Response()); + .mockResolvedValue(mockResponse as unknown as Response); await sandbox.compartment.globalThis['fetch']('https://example.com/api', { method: 'GET', credentials: 'include', + headers: { + Authorization: 'Bearer token', + }, }); expect(fetchSpy).toHaveBeenCalledWith('https://example.com/api', { method: 'GET', credentials: 'omit', + headers: expect.objectContaining({ + Authorization: '', + }), }); + expect(ses.safeReturn).toHaveBeenCalledWith( + expect.objectContaining({ + ok: true, + status: 200, + statusText: 'OK', + url: 'https://example.com/api', + text: expect.any(Function), + json: expect.any(Function), + }) + ); + fetchSpy.mockRestore(); }); @@ -122,4 +149,15 @@ describe('createSandbox', () => { Object.keys(sandbox.compartment.globalThis).filter((it) => !!it).length ).toBe(0); }); + + it('should return safe values for penpot methods via proxy', () => { + const sandbox = createSandbox(mockPlugin); + const mockPenpotMethod = vi.fn().mockReturnValue('penpot result'); + sandbox.compartment.globalThis['penpot'].mockMethod = mockPenpotMethod; + + const result = sandbox.compartment.globalThis['penpot'].mockMethod(); + + expect(ses.safeReturn).toHaveBeenCalledWith('penpot result'); + expect(result).toBe('penpot result'); + }); }); diff --git a/libs/plugins-runtime/src/lib/create-sandbox.ts b/libs/plugins-runtime/src/lib/create-sandbox.ts index 0d62ab2..cf219c7 100644 --- a/libs/plugins-runtime/src/lib/create-sandbox.ts +++ b/libs/plugins-runtime/src/lib/create-sandbox.ts @@ -2,7 +2,6 @@ import type { Penpot } from '@penpot/plugin-types'; import type { createPluginManager } from './plugin-manager'; import { createApi } from './api'; import { ses } from './ses.js'; - export function createSandbox( plugin: Awaited> ) { @@ -10,16 +9,51 @@ export function createSandbox( const pluginApi = createApi(plugin); - const publicPluginApi = { - penpot: ses.harden(pluginApi.penpot) as Penpot, - fetch: ses.harden((...args: Parameters) => { - const requestArgs: RequestInit = { - ...args[1], - credentials: 'omit', + const safeHandler = { + get(target: Penpot, prop: string, receiver: unknown) { + const originalValue = Reflect.get(target, prop, receiver); + + if (typeof originalValue === 'function') { + return function (...args: unknown[]) { + const result = originalValue.apply(target, args); + + return ses.safeReturn(result); + }; + } + + return ses.safeReturn(originalValue); + }, + }; + + const proxyApi = new Proxy(pluginApi.penpot, safeHandler); + + const safeFetch = (url: string, options: RequestInit) => { + const sanitizedOptions: RequestInit = { + ...options, + credentials: 'omit', + headers: { + ...options?.headers, + Authorization: '', + }, + }; + + return fetch(url, sanitizedOptions).then((response) => { + const safeResponse = { + ok: response.ok, + status: response.status, + statusText: response.statusText, + url: response.url, + text: response.text.bind(response), + json: response.json.bind(response), }; - return fetch(args[0], requestArgs); - }), + return ses.safeReturn(safeResponse); + }); + }; + + const publicPluginApi = { + penpot: proxyApi, + fetch: ses.harden(safeFetch), console: ses.harden(window.console), Math: ses.harden(Math), setTimeout: ses.harden( @@ -30,7 +64,7 @@ export function createSandbox( plugin.timeouts.add(timeoutId); - return timeoutId; + return ses.safeReturn(timeoutId); } ) as typeof setTimeout, clearTimeout: ses.harden((id: ReturnType) => { diff --git a/libs/plugins-runtime/src/lib/load-plugin.spec.ts b/libs/plugins-runtime/src/lib/load-plugin.spec.ts index 07a52a7..647d410 100644 --- a/libs/plugins-runtime/src/lib/load-plugin.spec.ts +++ b/libs/plugins-runtime/src/lib/load-plugin.spec.ts @@ -19,6 +19,12 @@ vi.mock('./create-plugin', () => ({ createPlugin: vi.fn(), })); +vi.mock('./ses.js', () => ({ + ses: { + harden: vi.fn().mockImplementation((obj) => obj), + }, +})); + describe('plugin-loader', () => { let mockContext: Context; let manifest: Manifest; diff --git a/libs/plugins-runtime/src/lib/load-plugin.ts b/libs/plugins-runtime/src/lib/load-plugin.ts index adea7dd..bbeabbb 100644 --- a/libs/plugins-runtime/src/lib/load-plugin.ts +++ b/libs/plugins-runtime/src/lib/load-plugin.ts @@ -3,6 +3,7 @@ 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'; +import { ses } from './ses.js'; let plugins: Awaited>[] = []; @@ -44,9 +45,13 @@ export const loadPlugin = async function (manifest: Manifest) { closeAllPlugins(); - const plugin = await createPlugin(context, manifest, () => { - plugins = plugins.filter((api) => api !== plugin); - }); + const plugin = await createPlugin( + ses.harden(context) as Context, + manifest, + () => { + plugins = plugins.filter((api) => api !== plugin); + } + ); plugins.push(plugin); } catch (error) { diff --git a/libs/plugins-runtime/src/lib/ses.ts b/libs/plugins-runtime/src/lib/ses.ts index fb63a35..6c505cc 100644 --- a/libs/plugins-runtime/src/lib/ses.ts +++ b/libs/plugins-runtime/src/lib/ses.ts @@ -13,4 +13,11 @@ export const ses = { harden: (obj: Object) => { return harden(obj); }, + safeReturn(value: T): T { + if (value === null || value === undefined) { + return value; + } + + return harden(value) as T; + }, };