From 22846b4a9b52019db4000be9f6047daf945fcdcb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:28:11 +0200 Subject: [PATCH] fix: keep blob uploads privileged-only and derive the WebAuthn salt from the calling plugin --- lib/__tests__/plugin-api-gates.test.ts | 22 ++++++++++++++++++++++ lib/plugin-sandbox/host-api.ts | 8 ++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/__tests__/plugin-api-gates.test.ts b/lib/__tests__/plugin-api-gates.test.ts index b267c495f..27a895eb6 100644 --- a/lib/__tests__/plugin-api-gates.test.ts +++ b/lib/__tests__/plugin-api-gates.test.ts @@ -305,3 +305,25 @@ describe('native keyword extension API', () => { } }); }); + +describe('privileged-only methods', () => { + it('refuses jmap.uploadBlob to an untrusted plugin even with email:blob-write', async () => { + const p = plugin({ permissions: ['email:blob-write'], grantedPermissions: ['email:blob-write'] }); + expect(await gateError(p, 'jmap.uploadBlob', [new Uint8Array(1), 'a.bin', 'application/octet-stream'])) + .toMatch(/requires the privileged plugin tier/); + }); +}); + +describe('WebAuthn PRF salt', () => { + it("uses the calling plugin's id, not the one in the arguments", async () => { + const get = vi.fn(async () => { throw new Error('stop'); }); + Object.defineProperty(navigator, 'credentials', { value: { get }, configurable: true }); + const p = plugin({ id: 'mine', permissions: ['crypto:full'], managed: true, tier: 'privileged' }); + + await dispatchApiCall(p, 'crypto.getWebAuthn', [[1, 2, 3], 'someone-else'], { privileged: true }).catch(() => {}); + + const salt = (get.mock.calls[0] as unknown as [{ publicKey: { extensions: { prf: { eval: { first: Uint8Array } } } } }])[0] + .publicKey.extensions.prf.eval.first; + expect(new TextDecoder().decode(salt)).toBe('bulwark-plugins-v1mine'); + }); +}); diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index cf64ec690..26a0ab710 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -41,6 +41,8 @@ import { createHash } from 'crypto'; */ const PRIVILEGED_ONLY_METHODS = new Set([ 'jmap.fetchBlob', + // Puts arbitrary bytes on the mail server. Untrusted plugins attach files + // through the composer and draft APIs instead, which bound and label them. 'jmap.uploadBlob', 'jmap.sendRaw', 'jmap.submitRaw', @@ -1556,8 +1558,10 @@ export async function dispatchApiCall( case 'upfiles.get' : return getFile(args[0] as string); case 'upfiles.save' : return saveFile(args[0] as string, args[1] as File); - case 'crypto.createWebAuthn': return doCreatePRF(args[0] as string, args[1] as string, args[2] as string); - case 'crypto.getWebAuthn': return doGetPRF(args[0] as number[], args[1] as string); + // The PRF salt is derived from the calling plugin's id, never from an + // argument: otherwise one plugin could ask for another plugin's secret. + case 'crypto.createWebAuthn': return doCreatePRF(plugin.id, args[1] as string, args[2] as string); + case 'crypto.getWebAuthn': return doGetPRF(args[0] as number[], plugin.id); case 'crypto.getPublicKeys': return getPublicKeys(); case 'crypto.createPublicKey': return doCreatePublicKey(args[0] as PublicKeyInput); case 'crypto.removePublicKey': return doRemovePublicKey(args[0] as string);