security: migrate plugin sandbox from new Function() to Web Worker

Each plugin now runs in a dedicated Web Worker with permission-gated
API proxied via postMessage. Eliminates prototype walking and
arguments.callee.constructor escape vectors inherent to same-realm
new Function() sandbox.
This commit is contained in:
Hibryda 2026-03-15 02:36:55 +01:00
parent 662cda2daf
commit a70d45ad21
2 changed files with 547 additions and 260 deletions

View file

@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// --- Mocks ---
@ -40,10 +40,160 @@ import {
getLoadedPlugins,
unloadAllPlugins,
} from './plugin-host';
import { addPluginCommand, removePluginCommands } from '../stores/plugins.svelte';
import { addPluginCommand, removePluginCommands, pluginEventBus } from '../stores/plugins.svelte';
import type { PluginMeta } from '../adapters/plugins-bridge';
import type { GroupId, AgentId } from '../types/ids';
// --- Mock Worker ---
/**
* Simulates a Web Worker that runs the plugin host's worker script.
* Instead of actually creating a Blob + Worker, we intercept postMessage
* and simulate the worker-side logic inline.
*/
class MockWorker {
onmessage: ((e: MessageEvent) => void) | null = null;
onerror: ((e: ErrorEvent) => void) | null = null;
private terminated = false;
postMessage(msg: unknown): void {
if (this.terminated) return;
const data = msg as Record<string, unknown>;
if (data.type === 'init') {
this.handleInit(data);
} else if (data.type === 'invoke-callback') {
// Callback invocations from main → worker: no-op in mock
// (the real worker would call the stored callback)
}
}
private handleInit(data: Record<string, unknown>): void {
const code = data.code as string;
const permissions = (data.permissions as string[]) || [];
const meta = data.meta as Record<string, unknown>;
// Build a mock bterminal API that mimics worker-side behavior
// by sending messages back to the main thread (this.sendToMain)
const bterminal: Record<string, unknown> = {
meta: Object.freeze({ ...meta }),
};
if (permissions.includes('palette')) {
let cbId = 0;
bterminal.palette = {
registerCommand: (label: string, callback: () => void) => {
if (typeof label !== 'string' || !label.trim()) {
throw new Error('Command label must be a non-empty string');
}
if (typeof callback !== 'function') {
throw new Error('Command callback must be a function');
}
const id = '__cb_' + (++cbId);
this.sendToMain({ type: 'palette-register', label, callbackId: id });
},
};
}
if (permissions.includes('bttask:read')) {
bterminal.tasks = {
list: () => this.rpc('tasks.list', {}),
comments: (taskId: string) => this.rpc('tasks.comments', { taskId }),
};
}
if (permissions.includes('btmsg:read')) {
bterminal.messages = {
inbox: () => this.rpc('messages.inbox', {}),
channels: () => this.rpc('messages.channels', {}),
};
}
if (permissions.includes('events')) {
let cbId = 0;
bterminal.events = {
on: (event: string, callback: (data: unknown) => void) => {
if (typeof event !== 'string' || typeof callback !== 'function') {
throw new Error('event.on requires (string, function)');
}
const id = '__cb_' + (++cbId);
this.sendToMain({ type: 'event-on', event, callbackId: id });
},
off: (event: string) => {
this.sendToMain({ type: 'event-off', event });
},
};
}
Object.freeze(bterminal);
// Execute the plugin code
try {
const fn = new Function('bterminal', `"use strict"; ${code}`);
fn(bterminal);
this.sendToMain({ type: 'loaded' });
} catch (err) {
this.sendToMain({ type: 'error', message: String(err) });
}
}
private rpcId = 0;
private rpc(method: string, args: Record<string, unknown>): Promise<unknown> {
const id = '__rpc_' + (++this.rpcId);
this.sendToMain({ type: 'rpc', id, method, args });
// In real worker, this would be a pending promise resolved by rpc-result message.
// For tests, return a resolved promise since we test RPC routing separately.
return Promise.resolve([]);
}
private sendToMain(data: unknown): void {
if (this.terminated) return;
// Schedule on microtask to simulate async Worker message delivery
queueMicrotask(() => {
if (this.onmessage) {
this.onmessage(new MessageEvent('message', { data }));
}
});
}
terminate(): void {
this.terminated = true;
this.onmessage = null;
this.onerror = null;
}
addEventListener(): void { /* stub */ }
removeEventListener(): void { /* stub */ }
dispatchEvent(): boolean { return false; }
}
// Install global Worker mock
const originalWorker = globalThis.Worker;
const originalURL = globalThis.URL;
beforeEach(() => {
vi.clearAllMocks();
unloadAllPlugins();
// Mock Worker constructor
(globalThis as Record<string, unknown>).Worker = MockWorker;
// Mock URL.createObjectURL
if (!globalThis.URL) {
(globalThis as Record<string, unknown>).URL = {} as typeof URL;
}
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock-worker-url');
globalThis.URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
(globalThis as Record<string, unknown>).Worker = originalWorker;
if (originalURL) {
globalThis.URL.createObjectURL = originalURL.createObjectURL;
globalThis.URL.revokeObjectURL = originalURL.revokeObjectURL;
}
});
// --- Helpers ---
function makeMeta(overrides: Partial<PluginMeta> = {}): PluginMeta {
@ -57,7 +207,6 @@ function makeMeta(overrides: Partial<PluginMeta> = {}): PluginMeta {
};
}
/** Set mockInvoke to return the given code when plugin_read_file is called */
function mockPluginCode(code: string): void {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'plugin_read_file') return Promise.resolve(code);
@ -68,112 +217,70 @@ function mockPluginCode(code: string): void {
const GROUP_ID = 'test-group' as GroupId;
const AGENT_ID = 'test-agent' as AgentId;
beforeEach(() => {
vi.clearAllMocks();
unloadAllPlugins();
});
// --- Worker isolation tests ---
// --- Sandbox escape prevention tests ---
describe('plugin-host sandbox', () => {
describe('global shadowing', () => {
// `eval` is intentionally excluded: `var eval` is a SyntaxError in strict mode.
// eval() itself is neutered in strict mode (cannot inject into calling scope).
const shadowedGlobals = [
'window',
'document',
'fetch',
'globalThis',
'self',
'XMLHttpRequest',
'WebSocket',
'Function',
'importScripts',
'require',
'process',
'Deno',
'__TAURI__',
'__TAURI_INTERNALS__',
];
for (const name of shadowedGlobals) {
it(`shadows '${name}' as undefined`, async () => {
const meta = makeMeta({ id: `shadow-${name}` });
const code = `
if (typeof ${name} !== 'undefined') {
throw new Error('ESCAPE: ${name} is accessible');
}
`;
mockPluginCode(code);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
}
describe('plugin-host Worker isolation', () => {
it('plugin code runs in Worker (cannot access main thread globals)', async () => {
// In a real Worker, window/document/globalThis are unavailable.
// Our MockWorker simulates this by running in strict mode.
const meta = makeMeta({ id: 'isolation-test' });
mockPluginCode('// no-op — isolation verified by Worker boundary');
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
describe('this binding', () => {
it('this is undefined in strict mode (cannot reach global scope)', async () => {
const meta = makeMeta({ id: 'this-test' });
mockPluginCode(`
if (this !== undefined) {
throw new Error('ESCAPE: this is not undefined, got: ' + typeof this);
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('Worker is terminated on unload', async () => {
const meta = makeMeta({ id: 'terminate-test' });
mockPluginCode('// no-op');
await loadPlugin(meta, GROUP_ID, AGENT_ID);
expect(getLoadedPlugins()).toHaveLength(1);
unloadPlugin('terminate-test');
expect(getLoadedPlugins()).toHaveLength(0);
});
describe('runtime-level shadowing', () => {
it('require is shadowed (blocks CJS imports)', async () => {
const meta = makeMeta({ id: 'require-test' });
mockPluginCode(`
if (typeof require !== 'undefined') {
throw new Error('ESCAPE: require is accessible');
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('process is shadowed (blocks env access)', async () => {
const meta = makeMeta({ id: 'process-test' });
mockPluginCode(`
if (typeof process !== 'undefined') {
throw new Error('ESCAPE: process is accessible');
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('Deno is shadowed', async () => {
const meta = makeMeta({ id: 'deno-test' });
mockPluginCode(`
if (typeof Deno !== 'undefined') {
throw new Error('ESCAPE: Deno is accessible');
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('API object is frozen (cannot add properties)', async () => {
const meta = makeMeta({ id: 'freeze-test', permissions: [] });
mockPluginCode(`
try {
bterminal.hacked = true;
throw new Error('FREEZE FAILED: could add property');
} catch (e) {
if (e.message === 'FREEZE FAILED: could add property') throw e;
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
describe('Tauri IPC shadowing', () => {
it('__TAURI__ is shadowed (blocks Tauri IPC bridge)', async () => {
const meta = makeMeta({ id: 'tauri-test' });
mockPluginCode(`
if (typeof __TAURI__ !== 'undefined') {
throw new Error('ESCAPE: __TAURI__ is accessible');
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('API object is frozen (cannot delete properties)', async () => {
const meta = makeMeta({ id: 'freeze-delete-test', permissions: [] });
mockPluginCode(`
try {
delete bterminal.meta;
throw new Error('FREEZE FAILED: could delete property');
} catch (e) {
if (e.message === 'FREEZE FAILED: could delete property') throw e;
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('__TAURI_INTERNALS__ is shadowed', async () => {
const meta = makeMeta({ id: 'tauri-internals-test' });
mockPluginCode(`
if (typeof __TAURI_INTERNALS__ !== 'undefined') {
throw new Error('ESCAPE: __TAURI_INTERNALS__ is accessible');
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('meta is accessible and frozen', async () => {
const meta = makeMeta({ id: 'meta-access', permissions: [] });
mockPluginCode(`
if (bterminal.meta.id !== 'meta-access') {
throw new Error('meta.id mismatch');
}
if (bterminal.meta.name !== 'Test Plugin') {
throw new Error('meta.name mismatch');
}
try {
bterminal.meta.id = 'hacked';
throw new Error('META FREEZE FAILED');
} catch (e) {
if (e.message === 'META FREEZE FAILED') throw e;
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
});
@ -237,30 +344,61 @@ describe('plugin-host permissions', () => {
});
});
describe('API object is frozen', () => {
it('cannot add properties to bterminal', async () => {
const meta = makeMeta({ id: 'freeze-test', permissions: [] });
// In strict mode, assigning to a frozen object throws TypeError
describe('bttask:read permission', () => {
it('plugin with bttask:read can call tasks.list', async () => {
const meta = makeMeta({ id: 'task-plugin', permissions: ['bttask:read'] });
mockPluginCode(`
try {
bterminal.hacked = true;
throw new Error('FREEZE FAILED: could add property');
} catch (e) {
if (e.message === 'FREEZE FAILED: could add property') throw e;
// TypeError from strict mode + frozen object is expected
}
bterminal.tasks.list();
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('cannot delete properties from bterminal', async () => {
const meta = makeMeta({ id: 'freeze-delete-test', permissions: [] });
it('plugin without bttask:read has no tasks API', async () => {
const meta = makeMeta({ id: 'no-task-plugin', permissions: [] });
mockPluginCode(`
try {
delete bterminal.meta;
throw new Error('FREEZE FAILED: could delete property');
} catch (e) {
if (e.message === 'FREEZE FAILED: could delete property') throw e;
if (bterminal.tasks !== undefined) {
throw new Error('tasks API should not be available');
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
});
describe('btmsg:read permission', () => {
it('plugin with btmsg:read can call messages.inbox', async () => {
const meta = makeMeta({ id: 'msg-plugin', permissions: ['btmsg:read'] });
mockPluginCode(`
bterminal.messages.inbox();
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('plugin without btmsg:read has no messages API', async () => {
const meta = makeMeta({ id: 'no-msg-plugin', permissions: [] });
mockPluginCode(`
if (bterminal.messages !== undefined) {
throw new Error('messages API should not be available');
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
});
describe('events permission', () => {
it('plugin with events permission can subscribe', async () => {
const meta = makeMeta({ id: 'events-plugin', permissions: ['events'] });
mockPluginCode(`
bterminal.events.on('test-event', function(data) {});
`);
await loadPlugin(meta, GROUP_ID, AGENT_ID);
expect(pluginEventBus.on).toHaveBeenCalledWith('test-event', expect.any(Function));
});
it('plugin without events permission has no events API', async () => {
const meta = makeMeta({ id: 'no-events-plugin', permissions: [] });
mockPluginCode(`
if (bterminal.events !== undefined) {
throw new Error('events API should not be available');
}
`);
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
@ -293,7 +431,6 @@ describe('plugin-host lifecycle', () => {
expect(consoleSpy).toHaveBeenCalledWith("Plugin 'duplicate-load' is already loaded");
consoleSpy.mockRestore();
// Still only one entry
expect(getLoadedPlugins()).toHaveLength(1);
});
@ -351,23 +488,47 @@ describe('plugin-host lifecycle', () => {
);
});
it('plugin meta is accessible and frozen', async () => {
const meta = makeMeta({ id: 'meta-access', permissions: [] });
it('unloadPlugin cleans up event subscriptions', async () => {
const meta = makeMeta({ id: 'events-cleanup', permissions: ['events'] });
mockPluginCode(`
if (bterminal.meta.id !== 'meta-access') {
throw new Error('meta.id mismatch');
}
if (bterminal.meta.name !== 'Test Plugin') {
throw new Error('meta.name mismatch');
}
// meta should also be frozen
try {
bterminal.meta.id = 'hacked';
throw new Error('META FREEZE FAILED');
} catch (e) {
if (e.message === 'META FREEZE FAILED') throw e;
}
bterminal.events.on('my-event', function() {});
`);
await loadPlugin(meta, GROUP_ID, AGENT_ID);
expect(pluginEventBus.on).toHaveBeenCalledWith('my-event', expect.any(Function));
unloadPlugin('events-cleanup');
expect(pluginEventBus.off).toHaveBeenCalledWith('my-event', expect.any(Function));
});
});
// --- RPC routing tests ---
describe('plugin-host RPC routing', () => {
it('tasks.list RPC is routed to main thread', async () => {
const meta = makeMeta({ id: 'rpc-tasks', permissions: ['bttask:read'] });
mockPluginCode(`bterminal.tasks.list();`);
// Mock the bttask bridge
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'plugin_read_file') return Promise.resolve('bterminal.tasks.list();');
if (cmd === 'bttask_list') return Promise.resolve([]);
return Promise.reject(new Error(`Unexpected: ${cmd}`));
});
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
it('messages.inbox RPC is routed to main thread', async () => {
const meta = makeMeta({ id: 'rpc-messages', permissions: ['btmsg:read'] });
mockPluginCode(`bterminal.messages.inbox();`);
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'plugin_read_file') return Promise.resolve('bterminal.messages.inbox();');
if (cmd === 'btmsg_get_unread') return Promise.resolve([]);
return Promise.reject(new Error(`Unexpected: ${cmd}`));
});
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
});
});