diff --git a/lib/__tests__/plugin-hook-permissions.test.ts b/lib/__tests__/plugin-hook-permissions.test.ts new file mode 100644 index 000000000..35d937749 --- /dev/null +++ b/lib/__tests__/plugin-hook-permissions.test.ts @@ -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 { + 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); + }); +}); diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 6393177e4..08bb0d2b6 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -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 = { '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)) { diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 4753bf016..e5f8b4706 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -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` 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 { try { return await bg.invokeHook(hookName, args); @@ -188,12 +195,18 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { + 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, }); diff --git a/lib/plugin-sandbox/permissions.ts b/lib/plugin-sandbox/permissions.ts new file mode 100644 index 000000000..1f298b51e --- /dev/null +++ b/lib/plugin-sandbox/permissions.ts @@ -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 = { + // 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 = { + '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)); +}