mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
Only the host API methods checked a plugin's permissions. Every hook and slot the sandbox reported was wired as-is, so a plugin declaring no permissions could rewrite recipients and body of every outgoing mail through `onTransformOutgoingEmail`, replace displayed bodies through `onRenderEmailBody`, or read all fetched mail through `onEmailsFetched`. Admin-installed plugins are managed, so users never saw a consent prompt for any of it. Each hook and slot now maps to a permission in `lib/plugin-sandbox/permissions.ts`, checked the same way as API calls (declared, and managed or granted). Hooks that change what is sent need `email:send`, display takeovers need `email:render-takeover`, and a hook or slot missing from the table is refused. Logout and account switches stay implicit so plugins can always drop per-account state.
127 lines
5.2 KiB
TypeScript
127 lines
5.2 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { createHash } from 'node:crypto';
|
|
import type { InstalledPlugin, Permission } from '../plugin-types';
|
|
|
|
// Hooks and slots used to be wired for whatever the sandbox reported, with
|
|
// no look at the manifest: a plugin declaring no permissions could add a
|
|
// Bcc to every outgoing mail through onTransformOutgoingEmail and read every
|
|
// opened message through onRenderEmailBody.
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
background: { hooks: [] as string[], slots: [] as Array<{ name: string }> },
|
|
}));
|
|
|
|
const CODE = 'export default { activate() {} }';
|
|
const HASH = createHash('sha256').update(CODE, 'utf-8').digest('hex');
|
|
|
|
vi.mock('../plugin-storage', () => ({
|
|
pluginStorage: { getCode: async () => CODE, saveCode: async () => {}, deleteCode: vi.fn() },
|
|
}));
|
|
vi.mock('../plugin-sandbox/bundle-fetch', () => ({ downloadManagedBundle: vi.fn() }));
|
|
vi.mock('../plugin-sandbox/host-bridge', () => ({
|
|
createBackgroundInstance: () => ({
|
|
initPromise: Promise.resolve({ hooks: mocks.background.hooks, slots: mocks.background.slots, shortcuts: [] }),
|
|
invokeHook: vi.fn(async () => ({ to: ['a@example.org'], bcc: ['attacker@evil.example'] })),
|
|
destroy: vi.fn(),
|
|
}),
|
|
SandboxInstance: class {},
|
|
}));
|
|
vi.mock('../plugin-sandbox/host-api', () => ({ cancelPluginDialogs: vi.fn() }));
|
|
vi.mock('../plugin-sandbox/shortcuts', () => ({ registerShortcuts: () => () => {} }));
|
|
|
|
import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor } from '../plugin-sandbox/loader';
|
|
import { get as getActive } from '../plugin-sandbox/registry';
|
|
import { emailHooks, renderHooks, appLifecycleHooks } from '../plugin-hooks';
|
|
import { hookPermission, mayOfferSlot, mayRegisterHook, pluginHasPermission } from '../plugin-sandbox/permissions';
|
|
|
|
function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
|
|
return {
|
|
id: 'hook-plugin',
|
|
name: 'Hook plugin',
|
|
version: '1.0.0',
|
|
author: 'someone',
|
|
description: '',
|
|
type: 'hook',
|
|
permissions: [],
|
|
entrypoint: 'index.js',
|
|
enabled: true,
|
|
status: 'enabled',
|
|
settings: {},
|
|
managed: true,
|
|
adminApproved: true,
|
|
bundleHash: HASH,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
setSandboxStoreAccessor({ setPluginStatus: () => {} });
|
|
mocks.background.hooks = ['onTransformOutgoingEmail', 'onRenderEmailBody', 'onAppReady'];
|
|
mocks.background.slots = [{ name: 'email-banner' }, { name: 'plugin-dialog' }];
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
vi.spyOn(console, 'info').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
unloadSandboxedPlugin('hook-plugin');
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('sandboxed plugin hooks and slots', () => {
|
|
it('a zero-permission plugin cannot rewrite outgoing mail or read opened messages', async () => {
|
|
await loadSandboxedPlugin(plugin());
|
|
|
|
expect(emailHooks.onTransformOutgoingEmail.size).toBe(0);
|
|
expect(renderHooks.onRenderEmailBody.size).toBe(0);
|
|
const outgoing = await emailHooks.onTransformOutgoingEmail.transform({ to: ['a@example.org'], bcc: [] });
|
|
expect(outgoing).toEqual({ to: ['a@example.org'], bcc: [] });
|
|
|
|
// Implicit permissions still work.
|
|
expect(appLifecycleHooks.onAppReady.size).toBe(1);
|
|
expect(getActive('hook-plugin')?.slotOffers.map((o) => o.name)).toEqual(['plugin-dialog']);
|
|
});
|
|
|
|
it('wires the hooks and slots a managed plugin declares', async () => {
|
|
await loadSandboxedPlugin(plugin({ permissions: ['email:send', 'email:render-takeover', 'ui:email-banner'] }));
|
|
|
|
expect(emailHooks.onTransformOutgoingEmail.size).toBe(1);
|
|
expect(renderHooks.onRenderEmailBody.size).toBe(1);
|
|
expect(getActive('hook-plugin')?.slotOffers.map((o) => o.name)).toEqual(['email-banner', 'plugin-dialog']);
|
|
});
|
|
|
|
it('holds back a declared permission the user has not granted', async () => {
|
|
await loadSandboxedPlugin(plugin({
|
|
managed: false,
|
|
permissions: ['email:send', 'email:render-takeover'],
|
|
grantedPermissions: ['email:render-takeover'],
|
|
}));
|
|
|
|
expect(emailHooks.onTransformOutgoingEmail.size).toBe(0);
|
|
expect(renderHooks.onRenderEmailBody.size).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('permission tables', () => {
|
|
it('refuses a hook it does not know', () => {
|
|
expect(hookPermission('onSomethingNew')).toBeNull();
|
|
expect(mayRegisterHook(plugin({ permissions: ['email:read'] }), 'onSomethingNew')).toBe(false);
|
|
expect(hookPermission('toString')).toBeNull();
|
|
});
|
|
|
|
it('refuses a slot it does not know', () => {
|
|
expect(mayOfferSlot(plugin({ permissions: ['ui:toolbar'] }), 'made-up-slot')).toBe(false);
|
|
expect(mayOfferSlot(plugin({ permissions: ['ui:toolbar'] }), 'constructor')).toBe(false);
|
|
});
|
|
|
|
it('accepts either permission for the admin page slot', () => {
|
|
expect(mayOfferSlot(plugin({ permissions: ['admin:config'] }), 'admin-plugin-page')).toBe(true);
|
|
expect(mayOfferSlot(plugin({ permissions: ['ui:admin-page'] }), 'admin-plugin-page')).toBe(true);
|
|
expect(mayOfferSlot(plugin({ permissions: [] }), 'admin-plugin-page')).toBe(false);
|
|
});
|
|
|
|
it('treats implicit permissions as granted', () => {
|
|
expect(pluginHasPermission(plugin({ managed: false }), 'app:lifecycle' as Permission)).toBe(true);
|
|
expect(mayRegisterHook(plugin(), 'onAfterLogout')).toBe(true);
|
|
});
|
|
});
|