feat: add privileged same-origin plugin tier + crypto API surface

This commit is contained in:
Linus Rath
2026-06-28 16:51:42 +02:00
parent 4cdc15fc3c
commit 512adab7e3
17 changed files with 321 additions and 18 deletions
+77
View File
@@ -6,9 +6,21 @@ import type { InstalledPlugin, Permission } from '../plugin-types';
import { IMPLICIT_PERMISSIONS } from '../plugin-types';
import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { apiFetch } from '../browser-navigation';
import { awaitDialog } from './host-dialog';
/**
* Methods only callable from the privileged (same-origin) tier. These expose
* raw message bytes and raw submission, which an untrusted null-origin plugin
* must never reach. Enforced in `dispatchApiCall` IN ADDITION to the per-method
* permission gate.
*/
const PRIVILEGED_ONLY_METHODS = new Set<string>([
'jmap.fetchBlob',
'jmap.sendRaw',
]);
const PERM_PER_METHOD: Record<string, Permission | null> = {
// storage is unscoped by the manifest - implicit.
'storage.get': null,
@@ -23,6 +35,9 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
// http
'http.post': 'http:post',
'http.fetch': 'http:fetch',
// jmap (privileged-tier only; see PRIVILEGED_ONLY_METHODS)
'jmap.fetchBlob': 'email:blob-read',
'jmap.sendRaw': 'email:raw-send',
// admin
'admin.getConfig': 'admin:config',
'admin.getAllConfig': 'admin:config',
@@ -201,6 +216,53 @@ async function doHttpFetch(plugin: InstalledPlugin, rawUrl: string, init?: Plugi
};
}
// ─── jmap (privileged tier) ───────────────────────────────────
/**
* Fetch the raw bytes of a blob by id, using the host's authenticated JMAP
* client. The plugin decides WHICH blobId to fetch (e.g. a pkcs7-mime part, or
* the full RFC822 message blob) and runs its own detection; the host only
* exposes the byte-fetch primitive. Returns a Uint8Array (structured-cloneable
* across the postMessage boundary).
*/
async function doJmapFetchBlob(blobId: string, opts?: { name?: string; type?: string }): Promise<Uint8Array> {
if (typeof blobId !== 'string' || !blobId) throw new Error('jmap.fetchBlob: blobId required');
const { client } = useAuthStore.getState();
if (!client) throw new Error('jmap.fetchBlob: no active session');
const buf = await client.fetchBlobArrayBuffer(blobId, opts?.name, opts?.type);
return new Uint8Array(buf);
}
/**
* Submit a fully-formed raw RFC822 message (e.g. one a plugin has signed and/or
* encrypted) via the host's raw-send path, which also files it into Sent. The
* plugin passes raw bytes; the host wraps them in a Blob.
*/
async function doJmapSendRaw(
rawBytes: ArrayBuffer | ArrayBufferView,
identityId: string,
opts?: { delayedUntil?: string; envelopeRecipients?: string[] },
): Promise<unknown> {
if (typeof identityId !== 'string' || !identityId) throw new Error('jmap.sendRaw: identityId required');
const { client } = useAuthStore.getState();
if (!client) throw new Error('jmap.sendRaw: no active session');
const view = rawBytes instanceof ArrayBuffer
? new Uint8Array(rawBytes)
: new Uint8Array(rawBytes.buffer, rawBytes.byteOffset, rawBytes.byteLength);
// Copy into a fresh ArrayBuffer-backed array so the Blob part is definitely
// ArrayBuffer (not SharedArrayBuffer) — also detaches from the caller's view.
const copy = new Uint8Array(view.byteLength);
copy.set(view);
const blob = new Blob([copy.buffer], { type: 'message/rfc822' });
return useEmailStore.getState().sendRawEmail(
client,
blob,
identityId,
opts?.delayedUntil,
opts?.envelopeRecipients,
);
}
// ─── admin config (same as before) ────────────────────────────
async function adminGetAll(pluginId: string): Promise<Record<string, unknown>> {
@@ -234,7 +296,15 @@ export async function dispatchApiCall(
plugin: InstalledPlugin,
method: string,
args: unknown[],
opts?: { privileged?: boolean },
): Promise<unknown> {
// Tier gate: privileged-only methods are refused for untrusted (null-origin)
// instances even if the permission is somehow present. Defence-in-depth on
// top of the load-time tier resolution.
if (PRIVILEGED_ONLY_METHODS.has(method) && !opts?.privileged) {
throw new Error(`Method "${method}" requires the privileged plugin tier`);
}
// Permission gate
const requiredPerm = PERM_PER_METHOD[method];
if (requiredPerm !== undefined && requiredPerm !== null) {
@@ -259,6 +329,13 @@ export async function dispatchApiCall(
case 'http.post': return doHttpPost(plugin, args[0] as string, args[1]);
case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined);
case 'jmap.fetchBlob': return doJmapFetchBlob(args[0] as string, args[1] as { name?: string; type?: string } | undefined);
case 'jmap.sendRaw': return doJmapSendRaw(
args[0] as ArrayBuffer | ArrayBufferView,
args[1] as string,
args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined,
);
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
case 'admin.getAllConfig': return adminGetAll(plugin.id);
case 'admin.setConfig': await adminSet(plugin.id, args[0] as string, args[1]); return undefined;