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