diff --git a/app/(sandbox)/plugin-sandbox-privileged/page.tsx b/app/(sandbox)/plugin-sandbox-privileged/page.tsx new file mode 100644 index 00000000..0f3d431d --- /dev/null +++ b/app/(sandbox)/plugin-sandbox-privileged/page.tsx @@ -0,0 +1,15 @@ +import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime'; + +// Privileged-tier sandbox route. Identical runtime to /plugin-sandbox, but the +// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle +// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin +// approval) is enforced host-side before this route is ever framed; the page +// itself carries no extra privilege. +// +// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in +// Next's injected hydration/chunk scripts. +export const dynamic = 'force-dynamic'; + +export default function PrivilegedPluginSandboxPage() { + return ; +} diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index 06562701..a62c4bf8 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -183,6 +183,7 @@ export async function POST(request: NextRequest) { author: manifest.author as string, description: (manifest.description as string) || '', type: manifest.type as string, + ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), permissions: (manifest.permissions as string[]) || [], entrypoint: manifest.entrypoint as string, enabled: true, diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index c6789a16..3cb3a63f 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -37,6 +37,9 @@ export async function GET() { author: p.author, description: p.description, type: p.type, + // Requested execution tier; clients gate the same-origin privileged + // sandbox on this (plus signature + approval + consent). + tier: p.tier, permissions: p.permissions, entrypoint: p.entrypoint, // Policy is the canonical source for force-enable. The per-plugin field diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx index 6156f2a0..b2b1e5b2 100644 --- a/components/plugins/plugin-iframe-slot.tsx +++ b/components/plugins/plugin-iframe-slot.tsx @@ -52,6 +52,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) { slot, code: active.code, locale, + tier: active.tier, extraProps: extraProps ?? {}, hostContainer: wrapperRef.current, onResize: (h) => setHeight(h), diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index 0913adf5..1ec02385 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -47,6 +47,9 @@ export interface ServerPlugin { author: string; description: string; type: string; + /** Requested execution tier ('untrusted' | 'privileged'). Privileged plugins + * run in a same-origin sandbox and require admin approval + consent. */ + tier?: string; permissions: string[]; entrypoint: string; enabled: boolean; diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 7f9bfd7e..53330b9d 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -249,6 +249,16 @@ export const emailHooks = { // recipients change. Handler receives a DraftView snapshot. Use for AI // assistants, grammar checkers, etc. onDraftChange: new HookBus(), + // Intercept hook - fires at the very TOP of the composer send path, before + // the host builds and submits the message. Handler receives a ComposeSend + // request (draft fields, recipients, identityId, attachments, and the user's + // sign/encrypt intent) and may TAKE OVER sending entirely: build a raw MIME + // message, sign/encrypt it, and submit it via `api.jmap.sendRaw`. Returning + // false signals "I handled the send" and the host SKIPS its default + // submission. Returning anything else (incl. undefined) lets the host send + // normally. This is the send-takeover hook used by the S/MIME plugin to + // replace the former native sign+encrypt+sendRaw pipeline. + onComposeSend: new HookBus(), }; // §7.2 Calendar Hooks @@ -520,6 +530,18 @@ export const renderHooks = { // Handlers return a new (or extended) badges array. // Rendered by the email list row component next to the subject line. onEmailListItemRender: new HookBus(), + // Transform hook - runs when an email is opened, BEFORE the viewer computes + // the body it will render. Initial value: RenderableBody { html, text, + // attachments, handledBy? }. Second argument: MessageContext { id, + // bodyStructure, attachments, blobId, contentType, from }. A handler may + // inspect the message (e.g. detect S/MIME), fetch the raw blob via + // `api.jmap.fetchBlob`, decrypt/verify in-frame, and return a REPLACED body + // with `handledBy` set plus optional `verification` status. Return undefined + // (or the unchanged value) to pass through. The host still runs the returned + // HTML through its sanitizer — plugin output is not trusted blindly. This is + // the render-takeover hook used by the S/MIME plugin to replace the former + // native detect/decrypt/verify path in the viewer. + onRenderEmailBody: new HookBus(), }; // ─── Aggregate: remove all handlers for a plugin across all buses ─── diff --git a/lib/plugin-sandbox/consent.ts b/lib/plugin-sandbox/consent.ts index 706799e7..97e6a1d9 100644 --- a/lib/plugin-sandbox/consent.ts +++ b/lib/plugin-sandbox/consent.ts @@ -57,6 +57,10 @@ const PERMISSION_LABELS: Record = { 'email:read': { title: 'Read your email', body: 'Access subjects, senders, recipients, body previews, and message bodies of your messages.' }, 'email:write': { title: 'Modify your email', body: 'Move, delete, flag, archive, or change keywords on your messages.' }, 'email:send': { title: 'Send mail and transform drafts', body: 'Compose and send messages, and modify content right before delivery.' }, + 'crypto:full': { title: 'Full cryptographic access (high risk)', body: 'Runs with full cryptographic access in a privileged, same-origin context. It can read your message bodies and private keys, store key material, and sign/encrypt on your behalf. Only enable plugins you fully trust — this is comparable to a full-access browser extension.' }, + 'email:raw-send': { title: 'Send raw messages', body: 'Submit fully-formed (e.g. signed or encrypted) messages on your behalf.' }, + 'email:blob-read': { title: 'Read raw message content', body: 'Fetch the raw bytes of your messages and attachments (needed to decrypt and verify them).' }, + 'email:render-takeover': { title: 'Replace rendered email content', body: 'Replace the displayed content of an opened message (e.g. to show decrypted text and a signature-verification badge).' }, 'calendar:read': { title: 'Read your calendar', body: 'Access events, calendars, RSVPs, and reminders.' }, 'calendar:write': { title: 'Modify your calendar', body: 'Create, edit, or delete events.' }, 'contacts:read': { title: 'Read your contacts', body: 'Access your address book entries.' }, diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 168c65ac..1f6c0cce 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -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([ + 'jmap.fetchBlob', + 'jmap.sendRaw', +]); + const PERM_PER_METHOD: Record = { // storage is unscoped by the manifest - implicit. 'storage.get': null, @@ -23,6 +35,9 @@ const PERM_PER_METHOD: Record = { // 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 { + 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 { + 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> { @@ -234,7 +296,15 @@ export async function dispatchApiCall( plugin: InstalledPlugin, method: string, args: unknown[], + opts?: { privileged?: boolean }, ): Promise { + // 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; diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 15cb31d9..cd0cf51b 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -7,9 +7,9 @@ // `event.source === iframe.contentWindow`. The iframe's runtime pins the // parent on the first inbound message. -import type { InstalledPlugin, SlotName } from '../plugin-types'; +import type { InstalledPlugin, SlotName, PluginTier } from '../plugin-types'; import { dispatchApiCall } from './host-api'; -import { SANDBOX_PATH } from './protocol'; +import { SANDBOX_PATH, SANDBOX_PRIVILEGED_PATH } from './protocol'; import { withBasePath } from '../browser-navigation'; import { snapshotHostTheme, type ThemeSnapshot } from './host-theme'; import type { @@ -55,6 +55,8 @@ export interface BackgroundOptions { plugin: InstalledPlugin; code: string; locale: string; + /** Resolved execution tier (from `resolvePluginTier`). */ + tier: PluginTier; /** Where the hidden iframe should attach. Defaults to document.body. */ hostContainer?: HTMLElement; } @@ -64,6 +66,8 @@ export interface SlotOptions { slot: SlotName; code: string; locale: string; + /** Resolved execution tier (from `resolvePluginTier`). */ + tier: PluginTier; extraProps: Record; /** Container element the visible slot iframe is mounted into. */ hostContainer: HTMLElement; @@ -83,6 +87,8 @@ export class SandboxInstance { readonly iframe: HTMLIFrameElement; readonly pluginId: string; readonly mode: 'background' | 'slot'; + /** True for the same-origin privileged tier; gates the origin assertion. */ + readonly privileged: boolean; readyPromise: Promise; initPromise: Promise; @@ -106,6 +112,7 @@ export class SandboxInstance { ) { this.pluginId = plugin.id; this.mode = initPayload.mode; + this.privileged = initPayload.tier === 'privileged'; // Slot iframes get `extraProps`; encode any function values now so the // structured-clone send doesn't drop them. @@ -120,12 +127,15 @@ export class SandboxInstance { }); this.iframe = document.createElement('iframe'); - // Dev-only: Next's HMR/dev runtime refuses requests from the opaque - // ("null") origin a strict sandbox produces, so the iframe never - // hydrates and `sandbox-ready` is never posted. Add allow-same-origin - // in dev so the iframe shares the host's origin and HMR works. - // Production keeps the strict opaque-origin sandbox. - const sandboxFlags = process.env.NODE_ENV === 'development' + // Privileged tier: same-origin in BOTH dev and prod so the iframe gets real + // `crypto.subtle` + IndexedDB and can run its own bundled crypto libs. The + // postMessage RPC membrane still applies; the trust gate is enforced + // host-side (signature + admin approval) BEFORE this instance is created. + // Untrusted tier: null-origin in prod; dev adds allow-same-origin only + // because Next's HMR/dev runtime refuses requests from the opaque ("null") + // origin a strict sandbox produces (the iframe would never hydrate and + // `sandbox-ready` would never post). + const sandboxFlags = this.privileged || process.env.NODE_ENV === 'development' ? 'allow-scripts allow-same-origin' : 'allow-scripts'; this.iframe.setAttribute('sandbox', sandboxFlags); @@ -148,7 +158,9 @@ export class SandboxInstance { // Prefix with the mount path so the sandbox route resolves under a // subpath deployment (NEXT_PUBLIC_BASE_PATH=/webmail). A bare // "/plugin-sandbox" would hit the origin root and 404, breaking plugins. - this.iframe.src = withBasePath(SANDBOX_PATH); + // Privileged plugins load the same-origin route so the CSP/allow-same-origin + // pairing is consistent. + this.iframe.src = withBasePath(this.privileged ? SANDBOX_PRIVILEGED_PATH : SANDBOX_PATH); this.listener = (ev) => this.onMessage(ev); window.addEventListener('message', this.listener); @@ -174,6 +186,11 @@ export class SandboxInstance { private onMessage(ev: MessageEvent): void { if (this.destroyed) return; if (ev.source !== this.iframe.contentWindow) return; + // Privileged iframes are same-origin, so we can additionally pin the origin + // (defence-in-depth on top of the contentWindow check). Untrusted iframes + // are null-origin (event.origin === "null") in prod and can't be pinned + // this way, so the contentWindow check above is the sole gate for them. + if (this.privileged && ev.origin !== window.location.origin) return; const msg = ev.data as SandboxToHost; if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return; @@ -194,7 +211,7 @@ export class SandboxInstance { const { id, method, args } = msg; void (async () => { try { - const result = await dispatchApiCall(this.plugin, method, args ?? []); + const result = await dispatchApiCall(this.plugin, method, args ?? [], { privileged: this.privileged }); this.send({ type: 'api-response', id, ok: true, result }); } catch (err) { this.send({ type: 'api-response', id, ok: false, error: (err as Error).message ?? String(err) }); @@ -318,6 +335,7 @@ export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstan const payload: InitPayload = { mode: 'background', pluginId: opts.plugin.id, + tier: opts.tier, manifest: { id: opts.plugin.id, version: opts.plugin.version, @@ -341,6 +359,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance { const payload: InitPayload = { mode: 'slot', pluginId: opts.plugin.id, + tier: opts.tier, slot: opts.slot, code: opts.code, manifest: { diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 48eda3ae..013cd09b 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -14,6 +14,7 @@ import { } from '../plugin-hooks'; import { verifyBundle } from './bundle-integrity'; import { createBackgroundInstance } from './host-bridge'; +import { resolvePluginTier } from './tier'; import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry'; import { cancelPluginDialogs } from './host-api'; import { registerShortcuts } from './shortcuts'; @@ -93,11 +94,22 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise | null = null; try { + // Decide the execution tier BEFORE creating any iframe. A refused privileged + // request is a hard error (never silently downgraded to null-origin). + const resolution = resolvePluginTier(plugin); + if (resolution.tier === null) { + storeAccessor?.setPluginStatus(plugin.id, 'error', resolution.error); + console.error(`[plugin-sandbox] "${plugin.id}" tier refused: ${resolution.error}`); + return; + } + const tier = resolution.tier; + const code = await getBundleCode(plugin); background = createBackgroundInstance({ plugin, code, locale: currentLocale, + tier, }); // Wait for the background runtime to evaluate the bundle, register hooks, @@ -138,6 +150,7 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise) => callApi('http.post', [path, body]), fetch: (url: string, init?: unknown) => callApi('http.fetch', [url, init]), }, + // Privileged-tier only (same-origin plugins). Calls throw for untrusted + // plugins (the host refuses the method) — these power crypto plugins that + // need raw message bytes and raw submission. + jmap: { + /** Fetch a blob's raw bytes by id. Resolves to a Uint8Array. */ + fetchBlob: (blobId: string, opts?: { name?: string; type?: string }) => + callApi('jmap.fetchBlob', [blobId, opts]) as Promise, + /** Submit a fully-formed raw RFC822 message (already signed/encrypted). */ + sendRaw: ( + rawBytes: ArrayBuffer | ArrayBufferView, + identityId: string, + opts?: { delayedUntil?: string; envelopeRecipients?: string[] }, + ) => callApi('jmap.sendRaw', [rawBytes, identityId, opts]), + }, toast: { success: (m: string) => { void callApi('toast.success', [m]); }, error: (m: string) => { void callApi('toast.error', [m]); }, diff --git a/lib/plugin-sandbox/tier.ts b/lib/plugin-sandbox/tier.ts new file mode 100644 index 00000000..2861e2b9 --- /dev/null +++ b/lib/plugin-sandbox/tier.ts @@ -0,0 +1,64 @@ +// Single source of truth for which execution tier a plugin runs in. +// +// The decision is security-critical: granting 'privileged' creates a +// same-origin iframe (full WebCrypto + IndexedDB + access to the host origin), +// so it must NEVER be granted to an unsigned or unapproved bundle. This helper +// is called by BOTH the loader (load gate, before the same-origin iframe is +// created) and the plugin store (enable gate), so the rules live in one place. +// +// A plugin that *requests* privileged but fails any gate is REFUSED (returns +// `{ tier: null, error }`), never silently downgraded — a crypto plugin cannot +// run in a null-origin sandbox, and a silent downgrade would mask tampering. + +import type { InstalledPlugin, PluginTier } from '../plugin-types'; + +export type TierResolution = + | { tier: PluginTier; error?: undefined } + | { tier: null; error: string }; + +/** + * Resolves the execution tier for a plugin. Returns `{ tier }` on success or + * `{ tier: null, error }` when a requested tier cannot be granted (the caller + * should put the plugin into an error state and NOT create an iframe). + * + * Privileged tier gates (ALL required): + * 1. Manifest declares the umbrella high-risk permission `crypto:full`. + * 2. Signed bundle: only bundles delivered through the admin/server channel + * are Ed25519-signed (verified at download time — see `verifySignature` + * usage in the plugin store). Self-uploaded bundles are unsigned and can + * therefore never reach the privileged tier. `managed` is the signal that + * the bundle came through that signed channel. + * 3. Admin approval pins operator trust in this specific bundle. + * 4. Explicit high-risk consent for `crypto:full` (granted via the consent + * dialog at enable time; admin-managed plugins are pre-approved). + */ +export function resolvePluginTier(plugin: InstalledPlugin): TierResolution { + if (plugin.tier !== 'privileged') { + return { tier: 'untrusted' }; + } + + // 1. Must declare the umbrella high-risk permission. + if (!plugin.permissions.includes('crypto:full')) { + return { tier: null, error: 'Privileged tier requires the "crypto:full" permission' }; + } + + // 2 + 3. Trust root: signed (managed) bundle AND admin approval. A bundle + // uploaded by the user directly carries no signature, so it cannot be + // privileged regardless of what its manifest claims. + if (!plugin.managed) { + return { tier: null, error: 'Privileged tier requires a signed bundle delivered through the admin channel' }; + } + if (!(plugin.adminApproved || plugin.managed)) { + return { tier: null, error: 'Privileged tier requires administrator approval' }; + } + + // 4. Explicit high-risk consent. Admin-managed plugins are pre-approved by + // the operator and skip the per-user prompt (mirrors the existing consent + // gate in the plugin store); otherwise the user must have granted crypto:full. + const consented = plugin.managed || (plugin.grantedPermissions ?? []).includes('crypto:full'); + if (!consented) { + return { tier: null, error: 'Privileged tier requires explicit consent for "crypto:full"' }; + } + + return { tier: 'privileged' }; +} diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index a7728746..ca2fea27 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -7,6 +7,18 @@ export type MaybePromise = T | Promise; export type PluginType = 'ui-extension' | 'sidebar-app' | 'hook' | 'theme'; export type PluginStatus = 'installed' | 'enabled' | 'running' | 'disabled' | 'error'; +/** + * Execution tier a plugin runs in. + * - 'untrusted' (default): null-origin sandbox iframe. No `crypto.subtle`, + * IndexedDB, or localStorage in-frame; all capabilities go through the host + * RPC. This is the only tier most plugins ever need. + * - 'privileged': same-origin sandbox iframe (full WebCrypto + IndexedDB) so a + * plugin can bundle its own crypto libs (e.g. pkijs for S/MIME, openpgp for + * PGP). Because same-origin == full host access, entering this tier is gated + * by a signed bundle + admin approval + high-risk consent — see + * `lib/plugin-sandbox/tier.ts` `resolvePluginTier`. + */ +export type PluginTier = 'untrusted' | 'privileged'; export type ThemeVariant = 'light' | 'dark'; // ─── Manifests ─────────────────────────────────────────────── @@ -97,6 +109,13 @@ export interface PluginManifest { author: string; description: string; type: Exclude; + /** + * Execution tier the plugin requests. Defaults to 'untrusted' when omitted. + * Declaring 'privileged' opts into the same-origin tier and requires the + * `crypto:full` permission, a signed bundle, and admin approval (enforced by + * `resolvePluginTier`). Most plugins should omit this. + */ + tier?: PluginTier; permissions: string[]; entrypoint: string; minAppVersion?: string; @@ -208,6 +227,9 @@ export interface InstalledPlugin { author: string; description: string; type: Exclude; + /** Execution tier carried over from the manifest at install time. Defaults + * to 'untrusted'. See `PluginTier` and `resolvePluginTier`. */ + tier?: PluginTier; permissions: string[]; entrypoint: string; enabled: boolean; @@ -852,6 +874,19 @@ export interface PluginI18n { export const ALL_PERMISSIONS = [ 'email:read', 'email:write', 'email:send', + // ─── Privileged-tier capabilities (require tier: 'privileged') ─── + // Umbrella high-risk permission gating same-origin crypto execution. A + // plugin holding this runs with full cryptographic access and can read + // message bodies and private keys; only granted to a signed, admin-approved + // privileged bundle after explicit high-risk consent. + 'crypto:full', + // Submit a fully-formed raw RFC822 message via JMAP (used after a plugin + // signs/encrypts an outgoing message itself). + 'email:raw-send', + // Fetch a message blob's raw bytes by blobId (for decrypt/verify). + 'email:blob-read', + // Replace the rendered body of an opened email (render-takeover). + 'email:render-takeover', 'calendar:read', 'calendar:write', 'contacts:read', 'contacts:write', 'files:read', 'files:write', diff --git a/proxy.ts b/proxy.ts index 5e97fbb5..c108e2f9 100644 --- a/proxy.ts +++ b/proxy.ts @@ -75,10 +75,17 @@ export async function proxy(request: NextRequest) { const nonce = crypto.randomUUID(); const isDev = process.env.NODE_ENV === "development"; // The plugin-sandbox iframe document needs `'unsafe-eval'` to run plugin - // bundles via `new Function`. It is null-origin (sandbox="allow-scripts"), - // so the relaxation is scoped strictly to that document and never reaches - // the main app, plus it must be embeddable from `'self'`. - const isSandboxPath = pathname === "/plugin-sandbox" || pathname.startsWith("/plugin-sandbox/"); + // bundles via `new Function`. The untrusted route is null-origin + // (sandbox="allow-scripts"); the privileged route is same-origin + // (allow-same-origin) so a vetted plugin gets real WebCrypto + IndexedDB. + // Both get the SAME CSP relaxations (unsafe-eval, frame-ancestors 'self'); + // the privileged route's extra power comes from the iframe sandbox flag the + // host sets, gated by signature + admin approval, NOT from a wider CSP. + const isSandboxPath = + pathname === "/plugin-sandbox" || + pathname.startsWith("/plugin-sandbox/") || + pathname === "/plugin-sandbox-privileged" || + pathname.startsWith("/plugin-sandbox-privileged/"); const scriptSrc = isSandboxPath ? `'self' 'nonce-${nonce}' 'unsafe-eval'` diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index ad8d0604..2e98df1d 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -3,7 +3,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import type { InstalledPlugin, PluginStatus } from '@/lib/plugin-types'; +import type { InstalledPlugin, PluginStatus, PluginTier } from '@/lib/plugin-types'; import { pluginStorage } from '@/lib/plugin-storage'; import { extractPlugin } from '@/lib/plugin-validator'; import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable, setSandboxLocale } from '@/lib/plugin-loader'; @@ -76,6 +76,7 @@ export const usePluginStore = create()( author: manifest.author, description: manifest.description, type: manifest.type, + ...(manifest.tier ? { tier: manifest.tier } : {}), permissions: manifest.permissions, entrypoint: manifest.entrypoint, enabled: false, // Start disabled, user must enable @@ -324,6 +325,8 @@ interface ServerPluginInfo { author: string; description: string; type: string; + /** Requested execution tier (privileged plugins run same-origin). */ + tier?: PluginTier; permissions: string[]; entrypoint: string; forceEnabled: boolean; @@ -357,6 +360,7 @@ function serverMeta(sp: ServerPluginInfo) { description: sp.description, permissions: sp.permissions, entrypoint: sp.entrypoint, + ...(sp.tier ? { tier: sp.tier } : {}), managed: true as const, forceEnabled: sp.forceEnabled, bundleHash: sp.bundleHash,