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
@@ -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 <SandboxRuntime />;
}
+1
View File
@@ -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,
+3
View File
@@ -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
@@ -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),
+3
View File
@@ -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;
+22
View File
@@ -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 ───
+4
View File
@@ -57,6 +57,10 @@ const PERMISSION_LABELS: Record<string, { title: string; body: string }> = {
'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.' },
+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;
+29 -10
View File
@@ -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<string, unknown>;
/** 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<void>;
initPromise: Promise<InitDoneInfo>;
@@ -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: {
+13
View File
@@ -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<void
let background: ReturnType<typeof createBackgroundInstance> | 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<void
registerActive({
plugin,
code,
tier,
background: bg,
slotOffers: info.slots,
hookDisposables,
+20 -2
View File
@@ -8,7 +8,7 @@
// the boundary must be structured-cloneable: no functions, no DOM nodes, no
// class instances.
import type { SlotName } from '../plugin-types';
import type { SlotName, PluginTier } from '../plugin-types';
import type { ThemeSnapshot } from './host-theme';
// ─── Sandbox mode ────────────────────────────────────────────
@@ -19,6 +19,12 @@ export type SandboxMode = 'background' | 'slot';
export interface BackgroundInit {
mode: 'background';
pluginId: string;
/**
* Execution tier. 'privileged' iframes are same-origin (real WebCrypto +
* IndexedDB); 'untrusted' iframes are null-origin. Decided host-side by
* `resolvePluginTier`; the sandbox itself does not act on this field.
*/
tier: PluginTier;
/** Trimmed manifest visible to the plugin. No host secrets. */
manifest: {
id: string;
@@ -38,6 +44,8 @@ export interface BackgroundInit {
export interface SlotInit {
mode: 'slot';
pluginId: string;
/** Execution tier (mirrors `BackgroundInit.tier`). */
tier: PluginTier;
/** Slot name the iframe should render a component for. */
slot: SlotName;
/** Same bundle code as the background instance. */
@@ -217,13 +225,23 @@ export function isSandboxMessage(value: unknown): value is SandboxToHost {
// ─── Constants ───────────────────────────────────────────────
/** Path used for the sandbox iframe `src`. Matched in `proxy.ts` for CSP. */
/** Path used for the untrusted (null-origin) sandbox iframe `src`. Matched in
* `proxy.ts` for CSP. */
export const SANDBOX_PATH = '/plugin-sandbox';
/**
* Path used for the privileged (same-origin) sandbox iframe `src`. A distinct
* route so the iframe gets `allow-same-origin` (real WebCrypto + IndexedDB)
* while keeping the same CSP relaxations as the untrusted sandbox. Matched in
* `proxy.ts`. Renders the identical `SandboxRuntime`.
*/
export const SANDBOX_PRIVILEGED_PATH = '/plugin-sandbox-privileged';
/** Methods callable by a plugin via api-request. Host enforces permissions. */
export const API_METHODS = [
'storage.get', 'storage.set', 'storage.remove', 'storage.keys',
'http.post', 'http.fetch',
'jmap.fetchBlob', 'jmap.sendRaw',
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
'ui.confirm', 'ui.alert', 'ui.openExternalUrl',
+4 -1
View File
@@ -6,7 +6,7 @@
// `useSyncExternalStore` sees a stable reference between unrelated renders.
// The cache is invalidated whenever the set of active plugins changes.
import type { Disposable, InstalledPlugin, SlotName } from '../plugin-types';
import type { Disposable, InstalledPlugin, SlotName, PluginTier } from '../plugin-types';
import type { SandboxInstance } from './host-bridge';
export interface SlotOffer {
@@ -19,6 +19,9 @@ export interface ActivePlugin {
plugin: InstalledPlugin;
/** Verified bundle source. Reused when spinning up slot iframes. */
code: string;
/** Resolved execution tier. Slot iframes must use the SAME tier as the
* background instance, so `PluginIframeSlot` reads it from here. */
tier: PluginTier;
background: SandboxInstance;
slotOffers: SlotOffer[];
hookDisposables: Disposable[];
+14
View File
@@ -175,6 +175,20 @@ function buildPluginApi(manifest: PluginManifest) {
post: (path: string, body: Record<string, unknown>) => 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<Uint8Array>,
/** 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]); },
+64
View File
@@ -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' };
}
+35
View File
@@ -7,6 +7,18 @@ export type MaybePromise<T> = T | Promise<T>;
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<PluginType, 'theme'>;
/**
* 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<PluginType, 'theme'>;
/** 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',
+11 -4
View File
@@ -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'`
+5 -1
View File
@@ -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<PluginStoreState>()(
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,