penpot/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts
Andrey Antukh 5f1e151e84
🐛 Validate plugin UI URLs cannot target Penpot's own domain (#11273)
* 🐛 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>
2026-09-08 09:23:20 +02:00

338 lines
8.6 KiB
TypeScript

import { describe, it, vi, expect, beforeEach, afterEach } from 'vitest';
import { createPluginManager } from './plugin-manager';
import { loadManifestCode, getValidUrl, prepareUrl } from './parse-manifest.js';
import { PluginModalElement } from './modal/plugin-modal.js';
import { openUIApi } from './api/openUI.api.js';
import { validateUIUrl } from './validate-url.js';
import type { Context, Theme } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js';
vi.mock('./parse-manifest.js', () => ({
loadManifestCode: vi.fn(),
getValidUrl: vi.fn(),
prepareUrl: vi.fn(),
}));
vi.mock('./api/openUI.api.js', () => ({
openUIApi: vi.fn(),
}));
vi.mock('./validate-url.js', () => ({
validateUIUrl: vi.fn(),
}));
describe('createPluginManager', () => {
let mockContext: Context;
let manifest: Manifest;
let onCloseCallback: () => void;
let onReloadModal: () => void;
let mockModal: {
setTheme: ReturnType<typeof vi.fn>;
remove: ReturnType<typeof vi.fn>;
addEventListener: ReturnType<typeof vi.fn>;
removeEventListener: ReturnType<typeof vi.fn>;
getAttribute: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
manifest = {
pluginId: 'test-plugin',
name: 'Test Plugin',
host: 'https://example.com',
code: '',
permissions: [
'content:read',
'content:write',
'library:read',
'library:write',
'user:read',
'comment:read',
'comment:write',
'allow:downloads',
'allow:localstorage',
],
};
mockModal = {
setTheme: vi.fn(),
remove: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getAttribute: vi.fn(),
};
vi.mocked(openUIApi).mockReturnValue(
mockModal as unknown as PluginModalElement,
);
mockContext = {
theme: 'light',
addListener: vi.fn().mockReturnValue(Symbol()),
removeListener: vi.fn(),
} as unknown as Context;
onCloseCallback = vi.fn();
onReloadModal = vi.fn();
vi.mocked(loadManifestCode).mockResolvedValue(
'console.log("Plugin loaded");',
);
vi.mocked(getValidUrl).mockReturnValue(
new URL('https://example.com/plugin'),
);
vi.mocked(prepareUrl).mockReturnValue('https://example.com/plugin');
});
afterEach(() => {
vi.clearAllMocks();
});
it('should load the plugin and set up listeners', async () => {
await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal,
);
expect(loadManifestCode).toHaveBeenCalledWith(manifest);
expect(mockContext.addListener).toHaveBeenCalledWith(
'themechange',
expect.any(Function),
);
expect(mockContext.addListener).toHaveBeenCalledWith(
'finish',
expect.any(Function),
);
});
it('should open a modal with the correct URL and theme', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal,
);
pluginManager.openModal('Test Modal', '/test-url', {
width: 400,
height: 300,
});
expect(prepareUrl).toHaveBeenCalledWith(manifest, '/test-url', {
theme: 'light',
});
expect(openUIApi).toHaveBeenCalledWith(
'Test Modal',
'https://example.com/plugin',
'light',
{ width: 400, height: 300 },
true,
false,
false,
);
expect(mockModal.setTheme).toHaveBeenCalledWith('light');
expect(mockModal.addEventListener).toHaveBeenCalledWith(
'close',
expect.any(Function),
{ once: true },
);
expect(mockModal.addEventListener).toHaveBeenCalledWith(
'load',
expect.any(Function),
);
});
it('should not open a new modal if the URL has not changed', async () => {
mockModal.getAttribute.mockReturnValue('https://example.com/plugin');
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal,
);
pluginManager.openModal('Test Modal', '/test-url');
pluginManager.openModal('Test Modal', '/test-url');
expect(openUIApi).toHaveBeenCalledTimes(1);
});
it('should handle theme changes and update the modal theme', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal,
);
pluginManager.openModal('Test Modal', '/test-url');
const themeChangeCallback = vi
.mocked(mockContext.addListener)
.mock.calls.find((call) => call[0] === 'themechange')?.[1];
if (!themeChangeCallback) {
throw new Error('Theme change callback not found');
}
themeChangeCallback('dark' as Theme);
expect(mockModal.setTheme).toHaveBeenCalledWith('dark');
});
it('should remove all event listeners and close the plugin', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal,
);
pluginManager.openModal('Test Modal', '/test-url');
pluginManager.close();
expect(mockContext.removeListener).toHaveBeenCalled();
expect(mockModal.removeEventListener).toHaveBeenCalledWith(
'close',
expect.any(Function),
);
expect(mockModal.remove).toHaveBeenCalled();
expect(onCloseCallback).toHaveBeenCalled();
});
it('shoud clean setTimeout when plugin is closed', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal,
);
pluginManager.timeouts.add(setTimeout(() => {}, 1000));
pluginManager.timeouts.add(setTimeout(() => {}, 1000));
expect(pluginManager.timeouts.size).toBe(2);
pluginManager.close();
expect(pluginManager.timeouts.size).toBe(0);
});
it('should reload the modal when reloaded', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal,
);
await pluginManager.openModal('Test Modal', '/test-url');
const loadCallback = mockModal.addEventListener.mock.calls.find((call) => {
return call[0] === 'load';
});
if (loadCallback) {
// initial load
await loadCallback[1]();
// reload
await loadCallback[1]();
expect(onReloadModal).toHaveBeenCalledWith(
'console.log("Plugin loaded");',
);
}
});
it('should register and trigger message callbacks', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal,
);
const callback = vi.fn();
pluginManager.registerMessageCallback(callback);
pluginManager.sendMessage('Test Message');
expect(callback).toHaveBeenCalledWith('Test Message');
});
it('should register and remove listeners', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal,
);
const callback = vi.fn();
const listenerId = pluginManager.registerListener('themechange', callback);
expect(mockContext.addListener).toHaveBeenCalledWith(
'themechange',
expect.any(Function),
undefined,
);
pluginManager.destroyListener(listenerId);
expect(mockContext.removeListener).toHaveBeenCalledWith(listenerId);
});
it('should clean up all event listeners on close', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal,
);
pluginManager.close();
expect(mockContext.removeListener).toHaveBeenCalled();
expect(onCloseCallback).toHaveBeenCalled();
});
it('should validate the modal URL before opening', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal,
);
pluginManager.openModal('Test Modal', '/test-url');
expect(validateUIUrl).toHaveBeenCalledWith(
'https://example.com/plugin',
manifest.host,
);
});
it('should throw when URL validation fails', async () => {
vi.mocked(validateUIUrl).mockImplementation(() => {
throw new Error("Plugin UI URL must not point to Penpot's own domain");
});
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal,
);
expect(() => pluginManager.openModal('Test Modal', '/test-url')).toThrow(
"Plugin UI URL must not point to Penpot's own domain",
);
expect(openUIApi).not.toHaveBeenCalled();
});
});