fix: keep blob uploads privileged-only and derive the WebAuthn salt from the calling plugin

This commit is contained in:
Linus Rath
2026-10-14 02:28:11 +02:00
parent 15cec58a46
commit 22846b4a9b
2 changed files with 28 additions and 2 deletions
+22
View File
@@ -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');
});
});
+6 -2
View File
@@ -41,6 +41,8 @@ import { createHash } from 'crypto';
*/
const PRIVILEGED_ONLY_METHODS = new Set<string>([
'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);