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.
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
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));
|
|
}
|