fix: require manifest permissions for plugin hooks and slots

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.
This commit is contained in:
Linus Rath
2026-09-26 14:26:33 +02:00
parent 1f19796cc2
commit f98779b0d0
4 changed files with 438 additions and 13 deletions
@@ -0,0 +1,126 @@
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);
});
});
+2 -12
View File
@@ -3,7 +3,7 @@
// structured-cloneable data back to the iframe.
import type { InstalledPlugin, Permission } from '../plugin-types';
import { IMPLICIT_PERMISSIONS } from '../plugin-types';
import { pluginHasPermission } from './permissions';
import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
@@ -165,16 +165,6 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
'sieve.regenerate': 'filters:write',
};
function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
if ((IMPLICIT_PERMISSIONS as readonly string[]).includes(perm)) return true;
if (!plugin.permissions.includes(perm)) return false;
// Defense-in-depth: even if the manifest declares a permission, the host
// refuses the API call unless an admin has marked the plugin as managed,
// or the user has explicitly granted it via the consent dialog.
if (plugin.managed) return true;
return (plugin.grantedPermissions ?? []).includes(perm);
}
// ─── Cross-origin allow-list (mirrors lib/plugin-api.ts) ──────
function originMatchesAllowlist(url: URL, allowlist: string[]): boolean {
@@ -1371,7 +1361,7 @@ export async function dispatchApiCall(
// Permission gate
const requiredPerm = PERM_PER_METHOD[method];
if (requiredPerm !== undefined && requiredPerm !== null) {
if (!hasPermission(plugin, requiredPerm)) {
if (!pluginHasPermission(plugin, requiredPerm)) {
throw new Error(`Plugin "${plugin.id}" lacks permission "${requiredPerm}"`);
}
} else if (!(method in PERM_PER_METHOD)) {
+14 -1
View File
@@ -21,6 +21,7 @@ import { resolvePluginTier } from './tier';
import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry';
import { cancelPluginDialogs } from './host-api';
import { registerShortcuts } from './shortcuts';
import { mayOfferSlot, mayRegisterHook } from './permissions';
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
@@ -165,6 +166,8 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
// Wire hook proxies: every hookName the plugin registered gets a HookBus
// entry whose handler dispatches into the sandbox. `shortcut:<id>` hooks
// are dispatched by the keyboard module separately and don't have a bus.
// A hook hands the plugin data or a say in what the host sends, so it
// needs the same manifest permission as the matching API call.
const hookDisposables: Disposable[] = [];
for (const hookName of info.hooks) {
if (hookName.startsWith('shortcut:')) continue;
@@ -173,6 +176,10 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
console.warn(`[plugin-sandbox] Plugin "${plugin.id}" registered unknown hook "${hookName}"`);
continue;
}
if (!mayRegisterHook(plugin, hookName)) {
console.warn(`[plugin-sandbox] Plugin "${plugin.id}" lacks the permission for hook "${hookName}"; not registered`);
continue;
}
const proxy = async (...args: unknown[]) => {
try {
return await bg.invokeHook(hookName, args);
@@ -188,12 +195,18 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
const shortcutDispose = registerShortcuts(bg, info.shortcuts ?? []);
hookDisposables.push({ dispose: shortcutDispose });
const slotOffers = info.slots.filter((offer) => {
if (mayOfferSlot(plugin, offer.name)) return true;
console.warn(`[plugin-sandbox] Plugin "${plugin.id}" lacks the permission for slot "${offer.name}"; not rendered`);
return false;
});
registerActive({
plugin,
code,
tier,
background: bg,
slotOffers: info.slots,
slotOffers,
hookDisposables,
});
+296
View File
@@ -0,0 +1,296 @@
import type { InstalledPlugin, Permission, SlotName } from '../plugin-types';
import { IMPLICIT_PERMISSIONS } from '../plugin-types';
/**
* Whether `plugin` may use `perm`: the manifest must declare it, and an
* admin must have installed the plugin (managed) or the user must have
* granted it in the consent dialog. Implicit permissions always pass.
*/
export function pluginHasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
if ((IMPLICIT_PERMISSIONS as readonly string[]).includes(perm)) return true;
if (!plugin.permissions.includes(perm)) return false;
// Defense-in-depth: even if the manifest declares a permission, the host
// refuses it unless an admin has marked the plugin as managed, or the user
// has explicitly granted it via the consent dialog.
if (plugin.managed) return true;
return (plugin.grantedPermissions ?? []).includes(perm);
}
// ─── Hooks ────────────────────────────────────────────────────
/**
* The permission each hook needs. A hook hands the plugin data (an opened
* message, the outgoing mail) or lets it change what the host does with it,
* so registering one is as much an API call as `email.get` and is gated the
* same way. A hook missing from this table is refused.
*/
const HOOK_PERMISSIONS: Record<string, Permission> = {
// Email: reading
onEmailOpen: 'email:read',
onEmailClose: 'email:read',
onEmailContentRender: 'email:read',
onThreadExpand: 'email:read',
onComposerOpen: 'email:read',
onAfterEmailSend: 'email:read',
onDraftAutoSave: 'email:read',
onAfterEmailDelete: 'email:read',
onAfterEmailMove: 'email:read',
onEmailArchive: 'email:read',
onEmailUnarchive: 'email:read',
onEmailReadStateChange: 'email:read',
onEmailStarToggle: 'email:read',
onEmailSpamToggle: 'email:read',
onEmailKeywordChange: 'email:read',
onMailboxChange: 'email:read',
onMailboxesRefresh: 'email:read',
onMailboxCreate: 'email:read',
onMailboxRename: 'email:read',
onMailboxDelete: 'email:read',
onMailboxEmpty: 'email:read',
onSearch: 'email:read',
onSearchResults: 'email:read',
onEmailSelectionChange: 'email:read',
onNewEmailReceived: 'email:read',
onPushConnectionChange: 'email:read',
onQuotaChange: 'email:read',
onMailtoIntercept: 'email:read',
onBeforeReply: 'email:read',
onBeforeReplyAll: 'email:read',
onBeforeForward: 'email:read',
onBeforeAttachmentUpload: 'email:read',
onAfterAttachmentUpload: 'email:read',
onAttachmentDownload: 'email:read',
onProvideSearchResults: 'email:read',
onDraftChange: 'email:read',
// Email: changing mail in the mailbox
onBeforeEmailDelete: 'email:write',
onBeforeEmailMove: 'email:write',
// Email: changing what is sent (recipients, body, drafts, the submission)
onBeforeCompose: 'email:send',
onBeforeEmailSend: 'email:send',
onTransformOutgoingEmail: 'email:send',
onComposeSend: 'email:send',
onBeforeDraftAutoSave: 'email:send',
onBeforeEditDraft: 'email:send',
onBuildQuoteHeader: 'email:send',
onRecipientChipsChange: 'email:send',
onBeforeComposeOpenToForwardAsAttachment: 'email:send',
onBeforeComposeOpenToReply: 'email:send',
onBeforeComposeOpenToReplyAll: 'email:send',
onBeforeComposeOpenToForward: 'email:send',
onBeforeBlobUpload: 'email:blob-write',
// Email: changing what is displayed
onEmailsFetched: 'email:render-takeover',
onAttachmentPreview: 'email:render-takeover',
onEmailListItemRender: 'email:render-takeover',
onRenderEmailBody: 'email:render-takeover',
// Email data reached through other surfaces
onAvatarResolve: 'email:read',
onDragStart: 'email:read',
onDragEnd: 'email:read',
onEmailDrop: 'email:read',
onTagDrop: 'email:read',
onBrowserNotification: 'email:read',
onNotificationClick: 'email:read',
onTextSelectionChange: 'email:read',
onBeforeEmailCategorize: 'email:read',
onEmailCategorize: 'email:read',
// Calendar
onCalendarEventOpen: 'calendar:read',
onAfterEventCreate: 'calendar:read',
onAfterEventUpdate: 'calendar:read',
onAfterEventDelete: 'calendar:read',
onEventRsvp: 'calendar:read',
onEventsImport: 'calendar:read',
onCalendarDateChange: 'calendar:read',
onCalendarViewChange: 'calendar:read',
onCalendarChange: 'calendar:read',
onCalendarVisibilityToggle: 'calendar:read',
onICalSubscriptionChange: 'calendar:read',
onCalendarAlert: 'calendar:read',
onCalendarAlertAcknowledge: 'calendar:read',
onCheckEventConflicts: 'calendar:read',
onCalendarEventFormOpen: 'calendar:read',
onBeforeEventCreate: 'calendar:write',
onBeforeEventUpdate: 'calendar:write',
onBeforeEventDelete: 'calendar:write',
onCalendarEventFormSave: 'calendar:write',
// Contacts
onContactOpen: 'contacts:read',
onAfterContactCreate: 'contacts:read',
onAfterContactUpdate: 'contacts:read',
onAfterContactDelete: 'contacts:read',
onContactsImport: 'contacts:read',
onContactSelectionChange: 'contacts:read',
onContactGroupChange: 'contacts:read',
onContactGroupMemberChange: 'contacts:read',
onContactMove: 'contacts:read',
onProvideRecipientSuggestions: 'contacts:read',
onBeforeContactCreate: 'contacts:write',
onBeforeContactUpdate: 'contacts:write',
onBeforeContactDelete: 'contacts:write',
// Files
onFileNavigate: 'files:read',
onAfterFileUpload: 'files:read',
onFileDownload: 'files:read',
onFileUploadCancel: 'files:read',
onDirectoryCreate: 'files:read',
onAfterFileDelete: 'files:read',
onFileRename: 'files:read',
onFileMove: 'files:read',
onFileCopy: 'files:read',
onFileDuplicate: 'files:read',
onFileFavoriteToggle: 'files:read',
onFileSelectionChange: 'files:read',
onFileUndo: 'files:read',
onBeforeFileUpload: 'files:write',
onBeforeFileDelete: 'files:write',
onBeforeFileRename: 'files:write',
// Sign-in state. Logout and account switches are lifecycle events every
// plugin needs to drop per-account state, so they stay implicit.
onLogin: 'auth:observe',
onAccountAdd: 'auth:observe',
onAccountRemove: 'auth:observe',
onTokenRefresh: 'auth:observe',
onAuthReady: 'auth:observe',
onBeforeLogout: 'app:lifecycle',
onAfterLogout: 'app:lifecycle',
onAccountSwitch: 'app:lifecycle',
// Carries the code of a plugin's own OAuth flow with a third party.
onOAuthCallback: 'http:fetch',
// Settings
onSettingChange: 'settings:read',
onSettingsExport: 'settings:read',
onSettingsReset: 'settings:read',
onSettingsSync: 'settings:read',
onKeywordChange: 'settings:read',
onTrustedSenderChange: 'settings:read',
onSettingsImport: 'settings:write',
// Identities
onIdentitiesLoaded: 'identity:read',
onIdentityCreate: 'identity:read',
onIdentityUpdate: 'identity:read',
onIdentityDelete: 'identity:read',
onIdentitySelect: 'identity:read',
onSignatureRender: 'identity:read',
// Filters
onFiltersLoaded: 'filters:read',
onFilterRuleChange: 'filters:read',
onFiltersSave: 'filters:read',
onSieveScriptChange: 'filters:read',
onSieveScriptGenerate: 'filters:write',
// Tasks, templates, S/MIME, vacation, account security
onTasksLoaded: 'tasks:read',
onTaskCreate: 'tasks:read',
onTaskUpdate: 'tasks:read',
onTaskDelete: 'tasks:read',
onTaskToggleComplete: 'tasks:read',
onTaskFilterChange: 'tasks:read',
onTemplateCreate: 'templates:read',
onTemplateUpdate: 'templates:read',
onTemplateDelete: 'templates:read',
onTemplateApply: 'templates:read',
onTemplatesImport: 'templates:read',
onTemplateRender: 'templates:read',
onSmimeKeyImport: 'smime:read',
onSmimeCertImport: 'smime:read',
onSmimeKeyStateChange: 'smime:read',
onSmimeDefaultsChange: 'smime:read',
onVacationLoaded: 'vacation:read',
onVacationUpdate: 'vacation:read',
onPasswordChange: 'security:read',
onTotpChange: 'security:read',
onAppPasswordChange: 'security:read',
onEncryptionChange: 'security:read',
onDisplayNameChange: 'security:read',
// UI and app state
onViewChange: 'ui:observe',
onSidebarToggle: 'ui:observe',
onSidebarCollapse: 'ui:observe',
onDeviceTypeChange: 'ui:observe',
onColumnResize: 'ui:observe',
onMobileBack: 'ui:observe',
onMobileViewSwitch: 'ui:observe',
onBeforeExternalLink: 'ui:observe',
onThemeChange: 'ui:observe',
onCustomThemeChange: 'ui:observe',
onLocaleChange: 'ui:observe',
onThemeBeforeApply: 'ui:observe',
onToastShow: 'ui:observe',
onToastDismiss: 'ui:observe',
onSidebarAppOpen: 'ui:observe',
onSidebarAppClose: 'ui:observe',
onSidebarAppChange: 'ui:observe',
onNavigate: 'ui:observe',
onRouteEnter: 'ui:observe',
onRouteLeave: 'ui:observe',
onTabsChange: 'ui:message-list-tabs',
onTabActivate: 'ui:message-list-tabs',
onTabCountsRefresh: 'ui:message-list-tabs',
onBeforeShortcut: 'ui:keyboard',
onAfterShortcut: 'ui:keyboard',
onAppReady: 'app:lifecycle',
onVisibilityChange: 'app:lifecycle',
onBeforeUnload: 'app:lifecycle',
onAppError: 'app:lifecycle',
onInterval: 'app:lifecycle',
onWindowFocus: 'app:lifecycle',
onWindowBlur: 'app:lifecycle',
onOnline: 'app:lifecycle',
onOffline: 'app:lifecycle',
};
export function hookPermission(hookName: string): Permission | null {
return Object.prototype.hasOwnProperty.call(HOOK_PERMISSIONS, hookName) ? HOOK_PERMISSIONS[hookName] : null;
}
export function mayRegisterHook(plugin: InstalledPlugin, hookName: string): boolean {
const perm = hookPermission(hookName);
return perm !== null && pluginHasPermission(plugin, perm);
}
// ─── Slots ────────────────────────────────────────────────────
/**
* Permissions that allow rendering into each slot (any one suffices). Slot
* components receive the data of the surface they sit on (the open message,
* the draft), so they are gated like hooks.
*/
const SLOT_PERMISSIONS: Record<SlotName, readonly Permission[]> = {
'toolbar-actions': ['ui:toolbar'],
'app-top-banner': ['ui:app-top-banner'],
'email-banner': ['ui:email-banner'],
'email-footer': ['ui:email-footer'],
'composer-toolbar': ['ui:composer-toolbar'],
'composer-sidebar': ['ui:composer-sidebar'],
'composer-sidebar-right': ['ui:composer-sidebar'],
'composer-attachment-source': ['ui:composer-toolbar', 'ui:composer-sidebar'],
'sidebar-widget': ['ui:sidebar-widget'],
'email-detail-sidebar': ['ui:email-details'],
'email-details-section': ['ui:email-details'],
'settings-section': ['ui:settings-section'],
'context-menu-email': ['ui:context-menu'],
'navigation-rail-bottom': ['ui:navigation-rail'],
'calendar-event-actions': ['ui:calendar-action'],
'admin-plugin-page': ['ui:admin-page', 'admin:config'],
'contact-cryptokeys': ['ui:contact-cryptokeys'],
'attachment-actions': ['email:read'],
// Only shown inside a dialog the plugin opened through the host API.
'plugin-dialog': ['ui:observe'],
};
export function mayOfferSlot(plugin: InstalledPlugin, slot: string): boolean {
const perms = Object.prototype.hasOwnProperty.call(SLOT_PERMISSIONS, slot)
? SLOT_PERMISSIONS[slot as SlotName]
: null;
return !!perms && perms.some((perm) => pluginHasPermission(plugin, perm));
}