fix(security): gate plugin hook registration on granted permissions

`info.hooks` is self-reported by the sandboxed bundle, and the loader
registered any recognised hook name without checking permissions. An
untrusted, null-origin plugin could therefore claim `onRenderEmailBody`
and replace the rendered body of any opened email without ever holding
`email:render-takeover` — the permission was enforced only by the
one-time consent dialog, i.e. it gated what the user was *asked*, not
what the host *allowed*.

Add HOOK_PERMISSIONS covering the hooks that can read message content,
alter outgoing mail, or observe key state: render takeover, the three
send-interception hooks, bulk-content hooks, attachment upload, and the
four S/MIME hooks. Hooks absent from the map stay unrestricted (UI
observation, toasts, navigation), so ordinary plugins are unaffected.

Refused hooks fail closed and log the missing permission by name — a
silently inert hook is far harder to diagnose than a refused one.

Export hasPermission() from host-api rather than reimplementing the rule
in the loader, so the hook gate and the RPC gate cannot drift apart.

Remaining ~200 hooks are tracked as B-09.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bernd Rodler
2026-08-04 08:57:14 +02:00
co-authored by Claude Opus 4.8
parent f0e63de09b
commit ae19ad888b
2 changed files with 61 additions and 3 deletions
+6 -1
View File
@@ -99,7 +99,12 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
'sieve.regenerate': 'filters:write',
};
function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
/**
* Single source of truth for "may this plugin use `perm`?". Exported so the
* loader can gate hook registration with the same rule the RPC layer uses -
* two copies of this logic would drift.
*/
export 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