feat: sandbox plugins in null-origin iframes with postMessage RPC

This commit is contained in:
Linus Rath
2026-05-18 10:50:49 +02:00
parent c5ac68e137
commit 9f312aa556
15 changed files with 1481 additions and 176 deletions
+17
View File
@@ -0,0 +1,17 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
export const metadata: Metadata = {
title: 'Plugin sandbox',
robots: { index: false, follow: false },
};
export default function PluginSandboxLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body style={{ margin: 0, padding: 0, background: 'transparent' }}>
{children}
</body>
</html>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
export const dynamic = 'force-static';
export default function PluginSandboxPage() {
return <SandboxRuntime />;
}
+3 -3
View File
@@ -92,7 +92,7 @@ import { parseTnef, isTnefAttachment } from "@/lib/tnef";
import { debug } from "@/lib/debug";
import type { TnefAttachment } from "@/lib/tnef";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginStore } from "@/stores/plugin-store";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { emailHooks, uiHooks } from "@/lib/plugin-hooks";
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
@@ -970,8 +970,8 @@ export function EmailViewer({
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false);
// Plugin detail sidebar state
const detailSlots = usePluginStore(s => s.slots['email-detail-sidebar']);
const hasDetailSidebar = detailSlots && detailSlots.length > 0;
const detailSlots = usePluginSlotOffers('email-detail-sidebar');
const hasDetailSidebar = detailSlots.length > 0;
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false);
const [detailSidebarWidth, setDetailSidebarWidth] = useState(280);
const detailSidebarWidthRef = useRef(280);
+74
View File
@@ -0,0 +1,74 @@
'use client';
// Sandboxed slot mount. One iframe per (plugin, slot) — created lazily after
// the background instance confirms `shouldShow(context)` (if defined). The
// iframe renders the plugin's slot component using the plugin's bundle in a
// null-origin context; its height is pushed back via postMessage and applied
// to a wrapper <div>.
import React, { useEffect, useRef, useState } from 'react';
import type { SlotName } from '@/lib/plugin-types';
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { createSlotInstance, type SandboxInstance } from '@/lib/plugin-sandbox/host-bridge';
interface Props {
pluginId: string;
slot: SlotName;
extraProps?: Record<string, unknown>;
}
export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
const wrapperRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<SandboxInstance | null>(null);
const [height, setHeight] = useState<number>(0);
// null = pending, true/false = decided
const [show, setShow] = useState<boolean | null>(null);
// Decide whether to mount based on the plugin's shouldShow (background-side).
useEffect(() => {
const active = getActivePlugin(pluginId);
if (!active) { setShow(false); return; }
const offer = active.slotOffers.find((o) => o.name === slot);
if (!offer) { setShow(false); return; }
if (!offer.hasShouldShow) { setShow(true); return; }
let cancelled = false;
active.background
.evaluateShouldShow(slot, extraProps ?? {})
.then((s) => { if (!cancelled) setShow(s); })
.catch(() => { if (!cancelled) setShow(false); });
return () => { cancelled = true; };
}, [pluginId, slot, extraProps]);
// Spawn / tear down the slot iframe.
useEffect(() => {
if (show !== true) return;
const active = getActivePlugin(pluginId);
if (!active || !wrapperRef.current) return;
const locale = (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ ?? 'en';
const inst = createSlotInstance({
plugin: active.plugin,
slot,
code: active.code,
locale,
extraProps: extraProps ?? {},
hostContainer: wrapperRef.current,
onResize: (h) => setHeight(h),
});
instanceRef.current = inst;
return () => {
try { inst.destroy(); } catch { /* ignore */ }
instanceRef.current = null;
};
// We intentionally don't depend on extraProps here — propagating prop
// changes happens via postMessage below to avoid iframe churn.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [show, pluginId, slot]);
// Push prop updates without remount.
useEffect(() => {
instanceRef.current?.updateProps(extraProps ?? {});
}, [extraProps]);
if (show !== true) return null;
return <div ref={wrapperRef} style={{ height, minHeight: height }} data-plugin-iframe-slot={`${pluginId}:${slot}`} />;
}
+14 -9
View File
@@ -1,9 +1,9 @@
'use client';
import React from 'react';
import React, { useSyncExternalStore } from 'react';
import type { SlotName } from '@/lib/plugin-types';
import { usePluginStore } from '@/stores/plugin-store';
import { PluginSlotRenderer } from './plugin-slot-renderer';
import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
import { PluginIframeSlot } from './plugin-iframe-slot';
interface PluginSlotProps {
name: SlotName;
@@ -12,16 +12,21 @@ interface PluginSlotProps {
}
export function PluginSlot({ name, className, extraProps }: PluginSlotProps) {
const registrations = usePluginStore(s => s.slots[name]);
const offers = useSyncExternalStore(
subscribe,
() => offersForSlot(name),
() => [],
);
if (!registrations || registrations.length === 0) return null;
if (offers.length === 0) return null;
return (
<div className={className} data-plugin-slot={name}>
{registrations.map((reg, i) => (
<PluginSlotRenderer
key={`${reg.pluginId}-${i}`}
registration={reg}
{offers.map((offer) => (
<PluginIframeSlot
key={offer.pluginId}
pluginId={offer.pluginId}
slot={name}
extraProps={extraProps}
/>
))}
+17
View File
@@ -0,0 +1,17 @@
'use client';
// Reactive accessor for sandboxed plugin slot offers. Components that gate
// layout on the *presence* of a plugin-supplied slot (e.g. a detail
// sidebar) read from here instead of the legacy `usePluginStore.slots` map.
import { useSyncExternalStore } from 'react';
import type { SlotName } from '@/lib/plugin-types';
import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
export function usePluginSlotOffers(slotName: SlotName) {
return useSyncExternalStore(
subscribe,
() => offersForSlot(slotName),
() => [],
);
}
+51 -157
View File
@@ -1,187 +1,81 @@
// Plugin Loader - loads and activates plugins via blob URL dynamic import
// Plugin loader entrypoint. Delegates to the iframe-based sandbox in
// `lib/plugin-sandbox/`. The legacy blob-URL `import()` path has been
// removed; plugin bundles now run in a null-origin sandbox iframe and
// communicate with the host via postMessage RPC.
import type { InstalledPlugin, Disposable } from './plugin-types';
import { pluginStorage } from './plugin-storage';
import { createPluginAPI, type PluginAPI } from './plugin-api';
import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks';
import { setPluginI18nLocale, clearPluginI18nTranslations } from './plugin-i18n';
import React from 'react';
import ReactDOM from 'react-dom';
import * as ReactJSX from 'react/jsx-runtime';
// --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
let localeSyncInitialised = false;
import type { InstalledPlugin } from './plugin-types';
import {
loadSandboxedPlugin,
unloadSandboxedPlugin,
activateAllSandboxed,
deactivateAllSandboxed,
setSandboxStoreAccessor,
setSandboxLocale,
setupSandboxAutoDisable,
} from './plugin-sandbox/loader';
import { all as allActive, get as getActive } from './plugin-sandbox/registry';
/**
* Previously: re-published React/ReactDOM on `globalThis.__PLUGIN_EXTERNALS__`
* so blob-imported plugin code could resolve `react`. With the sandbox model
* plugins receive React injected as a function argument inside their iframe
* runtime — there is nothing to expose on the host window.
*
* Kept as a no-op for callers that still invoke it during app bootstrap.
*/
export function exposePluginExternals(): void {
if (typeof window === 'undefined') return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).__PLUGIN_EXTERNALS__ = {
React,
ReactDOM,
ReactJSX,
};
// Sync plugin i18n with the app locale (runs once per page load)
if (!localeSyncInitialised) {
localeSyncInitialised = true;
// Dynamic import avoids a circular dependency chain at module evaluation time
import('@/stores/locale-store').then(({ useLocaleStore }) => {
setPluginI18nLocale(useLocaleStore.getState().locale);
useLocaleStore.subscribe((state) => setPluginI18nLocale(state.locale));
}).catch(() => {/* locale sync is best-effort */});
}
// Initialise the locale sync once. Importing the store lazily avoids the
// circular module graph we used to fight before the sandbox refactor.
void import('@/stores/locale-store').then(({ useLocaleStore }) => {
setSandboxLocale(useLocaleStore.getState().locale);
useLocaleStore.subscribe((state) => setSandboxLocale(state.locale));
// Mirror on a global so the slot-iframe component can read it at spawn.
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = useLocaleStore.getState().locale;
useLocaleStore.subscribe((state) => {
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = state.locale;
});
}).catch(() => { /* locale sync is best-effort */ });
}
// --- Active plugin tracking ----------------------------------
// ─── Store accessor (status updates) ──────────────────────────
interface ActivePlugin {
id: string;
api: PluginAPI;
disposable?: Disposable;
deactivate?: () => void;
type StoreAccessor = { setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void };
export function setPluginStoreAccessor(accessor: StoreAccessor): void {
setSandboxStoreAccessor(accessor);
}
const activePlugins = new Map<string, ActivePlugin>();
// --- Load a single plugin ------------------------------------
type PluginStoreAccessor = {
setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void;
};
let storeAccessor: PluginStoreAccessor | null = null;
export function setPluginStoreAccessor(accessor: PluginStoreAccessor): void {
storeAccessor = accessor;
}
// ─── Lifecycle (sandbox-backed) ───────────────────────────────
export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
if (activePlugins.has(plugin.id)) {
console.warn(`[plugin-loader] Plugin "${plugin.id}" is already loaded`);
if (getActive(plugin.id)) {
console.warn(`[plugin-loader] "${plugin.id}" is already loaded`);
return;
}
// Ensure React/ReactDOM are exposed before any plugin module evaluates
exposePluginExternals();
try {
// 1. Read bundle from IndexedDB
const code = await pluginStorage.getCode(plugin.id);
if (!code) {
throw new Error(`No code found in storage for plugin "${plugin.id}"`);
}
// 2. Create scoped module via blob URL
const blob = new Blob([code], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
// 3. Dynamic import (webpackIgnore prevents bundler processing)
let mod: { activate?: (api: PluginAPI) => void | Disposable; deactivate?: () => void };
try {
mod = await import(/* webpackIgnore: true */ url);
} finally {
URL.revokeObjectURL(url);
}
if (typeof mod.activate !== 'function') {
throw new Error(`Plugin "${plugin.id}" has no activate() export`);
}
// 4. Build sandboxed API
const api = createPluginAPI(plugin);
// 4b. Auto-register translations bundled in the manifest (plugin.locales)
// Plugins may still call api.i18n.addTranslations() in activate() to add more.
if (plugin.locales) {
for (const [locale, strings] of Object.entries(plugin.locales)) {
api.i18n.addTranslations(locale, strings);
}
}
// 5. Call activate
const disposable = await mod.activate(api);
// 6. Track active plugin
activePlugins.set(plugin.id, {
id: plugin.id,
api,
disposable: disposable && typeof disposable === 'object' && 'dispose' in disposable
? disposable as Disposable
: undefined,
deactivate: mod.deactivate,
});
// 7. Mark running
storeAccessor?.setPluginStatus(plugin.id, 'running');
console.info(`[plugin-loader] Plugin "${plugin.id}" activated`);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
storeAccessor?.setPluginStatus(plugin.id, 'error', errorMsg);
console.error(`[plugin-loader] Plugin "${plugin.id}" failed to load:`, err);
}
await loadSandboxedPlugin(plugin);
}
// --- Deactivate a single plugin ------------------------------
export function deactivatePlugin(pluginId: string): void {
const active = activePlugins.get(pluginId);
if (!active) return;
try {
// Call deactivate() if provided
active.deactivate?.();
// Dispose the disposable returned from activate()
active.disposable?.dispose();
} catch (err) {
console.error(`[plugin-loader] Error deactivating plugin "${pluginId}":`, err);
}
// Remove all hook subscriptions for this plugin
removeAllPluginHooks(pluginId);
// Clear cached translations (avoids memory leak on repeated enable/disable cycles)
clearPluginI18nTranslations(pluginId);
// Reset error tracker
pluginErrorTracker.reset(pluginId);
activePlugins.delete(pluginId);
storeAccessor?.setPluginStatus(pluginId, 'disabled');
console.info(`[plugin-loader] Plugin "${pluginId}" deactivated`);
unloadSandboxedPlugin(pluginId);
}
// --- Activate all enabled plugins ----------------------------
export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise<void> {
// Ensure externals are exposed
exposePluginExternals();
const enabledPlugins = plugins.filter(p => p.enabled && p.status !== 'error');
for (const plugin of enabledPlugins) {
await loadPlugin(plugin);
}
await activateAllSandboxed(plugins);
}
// --- Deactivate all plugins ---------------------------------
export function deactivateAllPlugins(): void {
for (const pluginId of [...activePlugins.keys()]) {
deactivatePlugin(pluginId);
}
deactivateAllSandboxed();
}
// --- Check if a plugin is active -----------------------------
export function isPluginActive(pluginId: string): boolean {
return activePlugins.has(pluginId);
return getActive(pluginId) !== undefined;
}
// --- Setup auto-disable callback -----------------------------
export function setupAutoDisable(): void {
pluginErrorTracker.setAutoDisableCallback((pluginId) => {
deactivatePlugin(pluginId);
storeAccessor?.setPluginStatus(pluginId, 'error', 'Auto-disabled due to repeated errors');
});
setupSandboxAutoDisable();
}
// Re-export for stores/tests that need the active set.
export { allActive as activePlugins };
+57
View File
@@ -0,0 +1,57 @@
// SHA-256 integrity check for plugin bundles.
//
// The bundle endpoint returns the canonical hash as the ETag. The client
// re-hashes the bytes after fetch and refuses to load on mismatch. This
// closes the gap where a compromised admin route (or transient MITM upstream
// of the CDN/proxy) could swap the bundle silently.
export async function sha256Hex(input: string | Uint8Array): Promise<string> {
// Re-wrap so the buffer is a plain ArrayBuffer (not SharedArrayBuffer) to
// satisfy lib.dom's BufferSource typing.
let buf: ArrayBuffer;
if (typeof input === 'string') {
buf = new TextEncoder().encode(input).buffer as ArrayBuffer;
} else {
const copy = new Uint8Array(input.byteLength);
copy.set(input);
buf = copy.buffer;
}
const digest = await crypto.subtle.digest('SHA-256', buf);
const view = new Uint8Array(digest);
let out = '';
for (let i = 0; i < view.length; i++) {
const h = view[i].toString(16);
out += h.length === 1 ? '0' + h : h;
}
return out;
}
/**
* Compare `actual` and `expected` in constant time. Both must be the same
* length lower-case hex strings. Returns false on any structural mismatch.
*/
export function constantTimeHexEqual(actual: string, expected: string): boolean {
if (typeof actual !== 'string' || typeof expected !== 'string') return false;
if (actual.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < actual.length; i++) {
diff |= actual.charCodeAt(i) ^ expected.charCodeAt(i);
}
return diff === 0;
}
/**
* Verify `code` against `expectedHash`. Returns the (normalised) hash on
* match, throws on mismatch. Pass `null`/`undefined` for `expectedHash` to
* compute-and-return without verification (used for dev-plugin paths).
*/
export async function verifyBundle(code: string, expectedHash: string | null | undefined): Promise<string> {
const actual = await sha256Hex(code);
if (!expectedHash) return actual;
// Server may quote the hash (it's also used as an ETag); strip and compare.
const normalised = expectedHash.replace(/^"|"$/g, '').trim().toLowerCase();
if (!constantTimeHexEqual(actual, normalised)) {
throw new Error(`Bundle integrity mismatch: expected ${normalised}, got ${actual}`);
}
return actual;
}
+234
View File
@@ -0,0 +1,234 @@
// Host-side implementations of the sandboxed plugin API. Every method gates
// on `plugin.permissions` BEFORE doing the underlying work, and only returns
// structured-cloneable data back to the iframe.
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 { apiFetch } from '../browser-navigation';
const PERM_PER_METHOD: Record<string, Permission | null> = {
// storage is unscoped by the manifest - implicit.
'storage.get': null,
'storage.set': null,
'storage.remove': null,
'storage.keys': null,
// toast / log don't need a permission (anyone can show a toast).
'toast.success': null,
'toast.error': null,
'toast.info': null,
'toast.warning': null,
// http
'http.post': 'http:post',
'http.fetch': 'http:fetch',
// admin
'admin.getConfig': 'admin:config',
'admin.getAllConfig': 'admin:config',
'admin.setConfig': 'admin:config',
'admin.deleteConfig': 'admin:config',
};
function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
if ((IMPLICIT_PERMISSIONS as readonly string[]).includes(perm)) return true;
return plugin.permissions.includes(perm);
}
// ─── Cross-origin allow-list (mirrors lib/plugin-api.ts) ──────
function originMatchesAllowlist(url: URL, allowlist: string[]): boolean {
if (url.protocol !== 'https:') return false;
for (const entry of allowlist) {
let parsed: URL;
try { parsed = new URL(entry.replace('*.', '')); } catch { continue; }
if (parsed.protocol !== 'https:') continue;
const port = url.port || '';
const expectedPort = parsed.port || '';
if (port !== expectedPort) continue;
if (entry.includes('*.')) {
const suffix = '.' + parsed.hostname.toLowerCase();
const host = url.hostname.toLowerCase();
if (host.endsWith(suffix)) {
const prefix = host.slice(0, host.length - suffix.length);
if (prefix.length > 0 && !prefix.includes('.')) return true;
}
} else if (url.hostname.toLowerCase() === parsed.hostname.toLowerCase()) {
return true;
}
}
return false;
}
// ─── Per-plugin storage namespace ─────────────────────────────
const STORAGE_PREFIX = (pluginId: string) => `plugin:${pluginId}:`;
function storageGet(pluginId: string, key: string): unknown {
if (typeof window === 'undefined') return null;
const raw = window.localStorage.getItem(STORAGE_PREFIX(pluginId) + key);
if (raw === null) return null;
try { return JSON.parse(raw); } catch { return null; }
}
function storageSet(pluginId: string, key: string, value: unknown): void {
if (typeof window === 'undefined') return;
window.localStorage.setItem(STORAGE_PREFIX(pluginId) + key, JSON.stringify(value));
}
function storageRemove(pluginId: string, key: string): void {
if (typeof window === 'undefined') return;
window.localStorage.removeItem(STORAGE_PREFIX(pluginId) + key);
}
function storageKeys(pluginId: string): string[] {
if (typeof window === 'undefined') return [];
const prefix = STORAGE_PREFIX(pluginId);
const out: string[] = [];
for (let i = 0; i < window.localStorage.length; i++) {
const k = window.localStorage.key(i);
if (k?.startsWith(prefix)) out.push(k.slice(prefix.length));
}
return out;
}
// ─── http.post (same-origin /api/*) ───────────────────────────
async function doHttpPost(path: string, body: unknown): Promise<{ ok: boolean; status: number; data: unknown }> {
if (typeof path !== 'string' || !path.startsWith('/api/')) {
throw new Error('path must start with /api/');
}
const url = new URL(path, window.location.origin);
if (url.origin !== window.location.origin) {
throw new Error('path must resolve to the same origin');
}
const { client } = useAuthStore.getState();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (client) {
headers['Authorization'] = client.getAuthHeader();
headers['X-JMAP-Username'] = client.getUsername();
}
const res = await fetch(url.pathname + url.search, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
const data = await res.json().catch(() => null);
return { ok: res.ok, status: res.status, data };
}
// ─── http.fetch (cross-origin, manifest-allowlisted) ──────────
interface PluginFetchInit {
method?: string;
headers?: Record<string, string>;
body?: string | ArrayBuffer | ArrayBufferView | null;
}
async function doHttpFetch(plugin: InstalledPlugin, rawUrl: string, init?: PluginFetchInit) {
if (typeof rawUrl !== 'string') throw new Error('url must be a string');
let url: URL;
try { url = new URL(rawUrl); } catch { throw new Error('url must be absolute https://'); }
const allowlist = plugin.httpOrigins ?? [];
if (allowlist.length === 0) {
throw new Error(`Plugin "${plugin.id}" has no httpOrigins declared`);
}
if (!originMatchesAllowlist(url, allowlist)) {
throw new Error(`Origin ${url.origin} not in plugin httpOrigins allowlist`);
}
const safeHeaders: Record<string, string> = {};
if (init?.headers) {
for (const [k, v] of Object.entries(init.headers)) {
const lower = k.toLowerCase();
if (lower === 'cookie' || lower === 'x-jmap-username') continue;
safeHeaders[k] = v;
}
}
const res = await fetch(url.toString(), {
method: init?.method ?? 'GET',
headers: safeHeaders,
body: (init?.body ?? undefined) as BodyInit | undefined,
credentials: 'omit',
mode: 'cors',
redirect: 'follow',
});
// Sandboxed plugin can't hold a Response object across the boundary, so
// we read the body once and return it as text + arrayBuffer (base64).
const headers: Record<string, string> = {};
res.headers.forEach((val, key) => { headers[key.toLowerCase()] = val; });
const buf = await res.arrayBuffer();
let text: string | null = null;
try { text = new TextDecoder('utf-8', { fatal: false }).decode(buf); } catch { text = null; }
return {
ok: res.ok,
status: res.status,
statusText: res.statusText,
headers,
bodyText: text,
bodyBytes: new Uint8Array(buf),
};
}
// ─── admin config (same as before) ────────────────────────────
async function adminGetAll(pluginId: string): Promise<Record<string, unknown>> {
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`);
if (!res.ok) return {};
return res.json();
}
async function adminGet(pluginId: string, key: string): Promise<unknown> {
const all = await adminGetAll(pluginId);
return all[key] ?? null;
}
async function adminSet(pluginId: string, key: string, value: unknown): Promise<void> {
await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
});
}
async function adminDelete(pluginId: string, key: string): Promise<void> {
await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
}
// ─── Dispatcher ──────────────────────────────────────────────
/** Resolves an api-request method against the per-plugin permissions. */
export async function dispatchApiCall(
plugin: InstalledPlugin,
method: string,
args: unknown[],
): Promise<unknown> {
// Permission gate
const requiredPerm = PERM_PER_METHOD[method];
if (requiredPerm !== undefined && requiredPerm !== null) {
if (!hasPermission(plugin, requiredPerm)) {
throw new Error(`Plugin "${plugin.id}" lacks permission "${requiredPerm}"`);
}
} else if (!(method in PERM_PER_METHOD)) {
throw new Error(`Unknown API method "${method}"`);
}
switch (method) {
case 'storage.get': return storageGet(plugin.id, args[0] as string);
case 'storage.set': storageSet(plugin.id, args[0] as string, args[1]); return undefined;
case 'storage.remove': storageRemove(plugin.id, args[0] as string); return undefined;
case 'storage.keys': return storageKeys(plugin.id);
case 'toast.success': appToast.success(String(args[0] ?? '')); return undefined;
case 'toast.error': appToast.error(String(args[0] ?? '')); return undefined;
case 'toast.info': appToast.info(String(args[0] ?? '')); return undefined;
case 'toast.warning': appToast.warning(String(args[0] ?? '')); return undefined;
case 'http.post': return doHttpPost(args[0] as string, args[1]);
case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | 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;
case 'admin.deleteConfig': await adminDelete(plugin.id, args[0] as string); return undefined;
default:
throw new Error(`Unhandled method "${method}"`);
}
}
+260
View File
@@ -0,0 +1,260 @@
// Host-side wrapper around a single sandbox iframe (one per plugin/background,
// plus one per slot mount). Owns the iframe lifecycle and the postMessage RPC.
//
// Origin model: the iframe is `sandbox="allow-scripts"` with no
// `allow-same-origin`, so its origin is opaque ("null"). We can't pin on
// `event.origin`; instead, every inbound message is gated on
// `event.source === iframe.contentWindow`. The iframe's runtime pins the
// parent on the first inbound message.
import type { InstalledPlugin, SlotName } from '../plugin-types';
import { dispatchApiCall } from './host-api';
import { SANDBOX_PATH } from './protocol';
import type {
SandboxToHost, HostToSandbox, InitMsg, InitPayload,
} from './protocol';
// ─── Public option types ─────────────────────────────────────
export interface BackgroundOptions {
plugin: InstalledPlugin;
code: string;
locale: string;
/** Where the hidden iframe should attach. Defaults to document.body. */
hostContainer?: HTMLElement;
}
export interface SlotOptions {
plugin: InstalledPlugin;
slot: SlotName;
code: string;
locale: string;
extraProps: Record<string, unknown>;
/** Container element the visible slot iframe is mounted into. */
hostContainer: HTMLElement;
/** Called whenever the sandbox reports a new content height. */
onResize: (height: number) => void;
}
export interface InitDoneInfo {
hooks: string[];
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number }>;
}
// ─── Sandbox instance ────────────────────────────────────────
export class SandboxInstance {
readonly iframe: HTMLIFrameElement;
readonly pluginId: string;
readonly mode: 'background' | 'slot';
readyPromise: Promise<void>;
initPromise: Promise<InitDoneInfo>;
private resolveReady!: () => void;
private resolveInit!: (info: InitDoneInfo) => void;
private rejectInit!: (err: Error) => void;
private listener: (ev: MessageEvent) => void;
private destroyed = false;
private pendingHookInvokes = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
private pendingShouldShow = new Map<string, (show: boolean) => void>();
constructor(
private plugin: InstalledPlugin,
initPayload: InitPayload,
hostContainer: HTMLElement,
private slotResizeCb: ((height: number) => void) | null,
) {
this.pluginId = plugin.id;
this.mode = initPayload.mode;
this.readyPromise = new Promise<void>((res) => { this.resolveReady = res; });
this.initPromise = new Promise<InitDoneInfo>((res, rej) => {
this.resolveInit = res;
this.rejectInit = rej;
});
this.iframe = document.createElement('iframe');
this.iframe.setAttribute('sandbox', 'allow-scripts');
this.iframe.setAttribute('referrerpolicy', 'no-referrer');
this.iframe.title = `plugin-${plugin.id}-${initPayload.mode}`;
this.iframe.style.border = 'none';
this.iframe.style.display = 'block';
if (initPayload.mode === 'background') {
this.iframe.style.position = 'absolute';
this.iframe.style.width = '1px';
this.iframe.style.height = '1px';
this.iframe.style.opacity = '0';
this.iframe.style.pointerEvents = 'none';
this.iframe.style.left = '-9999px';
this.iframe.setAttribute('aria-hidden', 'true');
} else {
this.iframe.style.width = '100%';
this.iframe.style.height = '0px';
}
this.iframe.src = SANDBOX_PATH;
this.listener = (ev) => this.onMessage(ev);
window.addEventListener('message', this.listener);
hostContainer.appendChild(this.iframe);
// Send init after the iframe runtime signals it's ready.
this.readyPromise.then(() => {
if (this.destroyed) return;
const msg: InitMsg = { type: 'init', payload: initPayload };
this.send(msg);
});
}
// ─── Internal ───────────────────────────────────────────────
private send(msg: HostToSandbox): void {
// targetOrigin '*' is required because the iframe is opaque-origin. The
// payload contains no host secrets — bundle code and manifest fields the
// plugin already owns.
this.iframe.contentWindow?.postMessage(msg, '*');
}
private onMessage(ev: MessageEvent): void {
if (this.destroyed) return;
if (ev.source !== this.iframe.contentWindow) return;
const msg = ev.data as SandboxToHost;
if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return;
switch (msg.type) {
case 'sandbox-ready':
this.resolveReady();
return;
case 'init-done':
this.resolveInit({ hooks: msg.hooks, slots: msg.slots });
return;
case 'init-error':
this.rejectInit(new Error(msg.error));
return;
case 'api-request': {
const { id, method, args } = msg;
void (async () => {
try {
const result = await dispatchApiCall(this.plugin, method, args ?? []);
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) });
}
})();
return;
}
case 'hook-result': {
const entry = this.pendingHookInvokes.get(msg.id);
if (!entry) return;
this.pendingHookInvokes.delete(msg.id);
if (msg.ok) entry.resolve(msg.result);
else entry.reject(new Error(msg.error ?? 'hook error'));
return;
}
case 'slot-should-show-result': {
const cb = this.pendingShouldShow.get(msg.id);
if (!cb) return;
this.pendingShouldShow.delete(msg.id);
cb(msg.show);
return;
}
case 'slot-resize':
this.slotResizeCb?.(msg.height);
return;
}
}
// ─── Public ─────────────────────────────────────────────────
/** Dispatch a hook handler inside the sandbox; resolves with its return value. */
invokeHook(hookName: string, args: unknown[]): Promise<unknown> {
if (this.destroyed) return Promise.reject(new Error('sandbox destroyed'));
const id = uid();
const p = new Promise<unknown>((resolve, reject) => {
this.pendingHookInvokes.set(id, { resolve, reject });
});
this.send({ type: 'hook-invoke', id, hookName, args });
return p;
}
/** Ask the background instance whether a slot should mount for this context. */
evaluateShouldShow(slot: SlotName, context: unknown): Promise<boolean> {
if (this.destroyed) return Promise.resolve(false);
const id = uid();
const p = new Promise<boolean>((resolve) => {
this.pendingShouldShow.set(id, resolve);
});
this.send({ type: 'slot-should-show', id, slot, context });
return p;
}
setLocale(locale: string): void {
if (this.destroyed) return;
this.send({ type: 'locale-change', locale });
}
updateProps(props: Record<string, unknown>): void {
if (this.destroyed) return;
this.send({ type: 'props-update', props });
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
window.removeEventListener('message', this.listener);
this.iframe.remove();
for (const { reject } of this.pendingHookInvokes.values()) {
reject(new Error('sandbox destroyed'));
}
this.pendingHookInvokes.clear();
this.pendingShouldShow.clear();
}
}
function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
// ─── Factory helpers ─────────────────────────────────────────
export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstance {
const payload: InitPayload = {
mode: 'background',
pluginId: opts.plugin.id,
manifest: {
id: opts.plugin.id,
version: opts.plugin.version,
permissions: opts.plugin.permissions,
settings: { ...opts.plugin.settings },
locales: opts.plugin.locales,
httpOrigins: opts.plugin.httpOrigins,
},
code: opts.code,
locale: opts.locale,
};
return new SandboxInstance(
opts.plugin,
payload,
opts.hostContainer ?? document.body,
null,
);
}
export function createSlotInstance(opts: SlotOptions): SandboxInstance {
const payload: InitPayload = {
mode: 'slot',
pluginId: opts.plugin.id,
slot: opts.slot,
code: opts.code,
extraProps: opts.extraProps,
locale: opts.locale,
};
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize);
}
+158
View File
@@ -0,0 +1,158 @@
// Iframe-based plugin loader. Replaces the blob-URL `import()` flow in
// `lib/plugin-loader.ts` with a postMessage-isolated sandbox.
import type { Disposable, InstalledPlugin } from '../plugin-types';
import { pluginStorage } from '../plugin-storage';
import {
emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
authHooks, settingsHooks, identityHooks, filterHooks,
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
removeAllPluginHooks, pluginErrorTracker,
} from '../plugin-hooks';
import { verifyBundle } from './bundle-integrity';
import { createBackgroundInstance } from './host-bridge';
import { register as registerActive, deregister as deregisterActive } from './registry';
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
type AnyBus = { register: (pluginId: string, handler: (...args: unknown[]) => unknown, order?: number) => Disposable };
const HOOK_BUSES: Record<string, AnyBus> = Object.assign({},
emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
authHooks, settingsHooks, identityHooks, filterHooks,
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
) as Record<string, AnyBus>;
// ─── Store accessor (status updates flow through the existing store) ──
type StoreAccessor = { setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void };
let storeAccessor: StoreAccessor | null = null;
export function setSandboxStoreAccessor(a: StoreAccessor): void { storeAccessor = a; }
// ─── Locale (kept in step with the app locale) ────────────────
let currentLocale = 'en';
export function setSandboxLocale(locale: string): void {
currentLocale = locale;
// Push to all active background instances.
// Slot iframes inherit locale at spawn time; they're short-lived.
// (We don't import the registry here to avoid a circular import; the
// PluginIframeSlot subscribes to locale changes on its own.)
}
// ─── Bundle fetch ─────────────────────────────────────────────
async function getBundleCode(plugin: InstalledPlugin): Promise<string> {
// Dev plugins are written into IndexedDB by the same install flow; the
// bundle endpoint is the source of truth for managed plugins. For Phase 1
// we read from IndexedDB to match the existing flow; the store-side install
// path already populates this from /api/admin/plugins/[id]/bundle.
const code = await pluginStorage.getCode(plugin.id);
if (!code) {
throw new Error(`No bundle in storage for plugin "${plugin.id}". Reinstall to populate.`);
}
await verifyBundle(code, plugin.bundleHash);
return code;
}
// ─── Load ─────────────────────────────────────────────────────
export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
if (typeof window === 'undefined') return;
try {
const code = await getBundleCode(plugin);
const background = createBackgroundInstance({
plugin,
code,
locale: currentLocale,
});
// Wait for the background runtime to evaluate the bundle, register hooks,
// and enumerate slots.
const info = await background.initPromise;
// Wire hook proxies: every hookName the plugin registered gets a HookBus
// entry whose handler dispatches into the sandbox.
const hookDisposables: Disposable[] = [];
for (const hookName of info.hooks) {
const bus = HOOK_BUSES[hookName];
if (!bus) {
console.warn(`[plugin-sandbox] Plugin "${plugin.id}" registered unknown hook "${hookName}"`);
continue;
}
const proxy = async (...args: unknown[]) => {
try {
return await background.invokeHook(hookName, args);
} catch (err) {
pluginErrorTracker.record(plugin.id, err);
throw err;
}
};
hookDisposables.push(bus.register(plugin.id, proxy as (...a: unknown[]) => unknown));
}
registerActive({
plugin,
code,
background,
slotOffers: info.slots,
hookDisposables,
});
storeAccessor?.setPluginStatus(plugin.id, 'running');
console.info(`[plugin-sandbox] "${plugin.id}" activated (hooks=${info.hooks.length}, slots=${info.slots.length})`);
} catch (err) {
const msg = (err as Error).message ?? String(err);
storeAccessor?.setPluginStatus(plugin.id, 'error', msg);
console.error(`[plugin-sandbox] Failed to load "${plugin.id}":`, err);
}
}
// ─── Unload ───────────────────────────────────────────────────
export function unloadSandboxedPlugin(pluginId: string): void {
const entry = deregisterActive(pluginId);
if (!entry) return;
for (const d of entry.hookDisposables) {
try { d.dispose(); } catch { /* ignore */ }
}
removeAllPluginHooks(pluginId);
try { entry.background.destroy(); } catch { /* ignore */ }
pluginErrorTracker.reset(pluginId);
storeAccessor?.setPluginStatus(pluginId, 'disabled');
console.info(`[plugin-sandbox] "${pluginId}" deactivated`);
}
// ─── Bulk ─────────────────────────────────────────────────────
export async function activateAllSandboxed(plugins: InstalledPlugin[]): Promise<void> {
const enabled = plugins.filter(p => p.enabled && p.status !== 'error');
for (const p of enabled) await loadSandboxedPlugin(p);
}
export function deactivateAllSandboxed(): void {
// import lazily to avoid a circular dep when registry mutates while we iterate.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { all } = require('./registry') as typeof import('./registry');
for (const e of all()) unloadSandboxedPlugin(e.plugin.id);
}
// ─── Auto-disable ─────────────────────────────────────────────
export function setupSandboxAutoDisable(): void {
pluginErrorTracker.setAutoDisableCallback((pluginId) => {
unloadSandboxedPlugin(pluginId);
storeAccessor?.setPluginStatus(pluginId, 'error', 'Auto-disabled due to repeated errors');
});
}
// ─── Re-export for compat with the existing loader name ───────
export { SandboxInstance } from './host-bridge';
+159
View File
@@ -0,0 +1,159 @@
// Shared message-protocol types for host ↔ sandbox postMessage RPC.
//
// The sandbox iframe is null-origin (`sandbox="allow-scripts"`), so postMessage
// events arrive with `event.origin === "null"`. The host pins messages by the
// iframe's `contentWindow` reference instead. All values crossing the boundary
// must be structured-cloneable: no functions, no DOM nodes, no class instances.
import type { SlotName } from '../plugin-types';
// ─── Sandbox mode ────────────────────────────────────────────
export type SandboxMode = 'background' | 'slot';
/** Initialisation payload for a background-instance iframe (one per plugin). */
export interface BackgroundInit {
mode: 'background';
pluginId: string;
/** Trimmed manifest visible to the plugin. No host secrets. */
manifest: {
id: string;
version: string;
permissions: string[];
settings: Record<string, unknown>;
locales?: Record<string, Record<string, string>>;
httpOrigins?: string[];
};
/** UTF-8 plugin bundle source (CommonJS). */
code: string;
/** Initial app locale; host pushes updates via 'locale-change'. */
locale: string;
}
/** Initialisation payload for a slot-instance iframe (one per slot mount). */
export interface SlotInit {
mode: 'slot';
pluginId: string;
/** Slot name the iframe should render a component for. */
slot: SlotName;
/** Same bundle code as the background instance. */
code: string;
/** Initial props the host passes through from `PluginSlot` `extraProps`. */
extraProps: Record<string, unknown>;
locale: string;
}
export type InitPayload = BackgroundInit | SlotInit;
// ─── Sandbox → Host messages ─────────────────────────────────
export interface ReadyMsg { type: 'sandbox-ready'; }
export interface InitDoneMsg {
type: 'init-done';
/** Hook names the plugin registered. The host installs proxy handlers. */
hooks: string[];
/** Slots the plugin claims. Used by the host to know when a slot is offered. */
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number }>;
}
export interface InitErrorMsg { type: 'init-error'; error: string; }
export interface ApiRequestMsg {
type: 'api-request';
id: string;
/** Dotted method path, e.g. "http.post", "storage.get", "admin.getConfig". */
method: string;
args: unknown[];
}
export interface HookResultMsg {
type: 'hook-result';
id: string;
ok: boolean;
result?: unknown;
error?: string;
}
export interface SlotResizeMsg {
type: 'slot-resize';
height: number;
}
export interface SlotShouldShowResultMsg {
type: 'slot-should-show-result';
id: string;
show: boolean;
}
export type SandboxToHost =
| ReadyMsg
| InitDoneMsg
| InitErrorMsg
| ApiRequestMsg
| HookResultMsg
| SlotResizeMsg
| SlotShouldShowResultMsg;
// ─── Host → Sandbox messages ─────────────────────────────────
export interface InitMsg { type: 'init'; payload: InitPayload; }
export interface ApiResponseMsg {
type: 'api-response';
id: string;
ok: boolean;
result?: unknown;
error?: string;
}
export interface HookInvokeMsg {
type: 'hook-invoke';
id: string;
hookName: string;
args: unknown[];
}
export interface LocaleChangeMsg { type: 'locale-change'; locale: string; }
export interface PropsUpdateMsg { type: 'props-update'; props: Record<string, unknown>; }
export interface SlotShouldShowMsg {
type: 'slot-should-show';
id: string;
slot: SlotName;
context: unknown;
}
export type HostToSandbox =
| InitMsg
| ApiResponseMsg
| HookInvokeMsg
| LocaleChangeMsg
| PropsUpdateMsg
| SlotShouldShowMsg;
// ─── Type guards ─────────────────────────────────────────────
export function isSandboxMessage(value: unknown): value is SandboxToHost {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { type?: unknown }).type === 'string'
);
}
// ─── Constants ───────────────────────────────────────────────
/** Path used for the sandbox iframe `src`. Matched in `proxy.ts` for CSP. */
export const SANDBOX_PATH = '/plugin-sandbox';
/** 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',
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
] as const;
export type ApiMethod = (typeof API_METHODS)[number];
+66
View File
@@ -0,0 +1,66 @@
// Process-wide registry of active sandboxed plugins. The loader populates it
// after a successful boot; PluginIframeSlot reads it to spawn slot iframes
// and to call evaluateShouldShow on the background instance.
import type { Disposable, InstalledPlugin, SlotName } from '../plugin-types';
import type { SandboxInstance } from './host-bridge';
export interface SlotOffer {
name: SlotName;
order: number;
hasShouldShow: boolean;
}
export interface ActivePlugin {
plugin: InstalledPlugin;
/** Verified bundle source. Reused when spinning up slot iframes. */
code: string;
background: SandboxInstance;
slotOffers: SlotOffer[];
hookDisposables: Disposable[];
}
const active = new Map<string, ActivePlugin>();
const listeners = new Set<() => void>();
function emit(): void { for (const l of listeners) try { l(); } catch { /* ignore */ } }
export function register(entry: ActivePlugin): void {
active.set(entry.plugin.id, entry);
emit();
}
export function deregister(pluginId: string): ActivePlugin | undefined {
const e = active.get(pluginId);
if (!e) return undefined;
active.delete(pluginId);
emit();
return e;
}
export function get(pluginId: string): ActivePlugin | undefined {
return active.get(pluginId);
}
export function all(): ActivePlugin[] {
return [...active.values()];
}
/** Returns active plugins ordered by `order`, that offer the requested slot. */
export function offersForSlot(slot: SlotName): Array<{ pluginId: string; order: number; hasShouldShow: boolean }> {
const out: Array<{ pluginId: string; order: number; hasShouldShow: boolean }> = [];
for (const entry of active.values()) {
for (const offer of entry.slotOffers) {
if (offer.name === slot) {
out.push({ pluginId: entry.plugin.id, order: offer.order, hasShouldShow: offer.hasShouldShow });
}
}
}
out.sort((a, b) => a.order - b.order);
return out;
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
+348
View File
@@ -0,0 +1,348 @@
'use client';
// Runtime that boots inside the null-origin plugin sandbox iframe.
//
// Lifecycle:
// 1. Iframe loads → posts 'sandbox-ready' to parent (targetOrigin '*' is OK;
// the message carries no secrets, and the parent's first inbound message
// gives us the origin to pin for everything that follows).
// 2. Parent posts 'init' with the bundle code + manifest + mode/slot.
// 3. We evaluate the bundle in a `new Function` scope with React/ReactDOM
// injected as globals; the bundle is CommonJS-style (`module.exports = {
// slots, hooks, activate }`). ES-module syntax inside the bundle is a
// build-time concern handled by the plugin's bundler.
// 4. In background mode: register hook handlers and call `activate(api)`.
// The host installs HookBus stubs and dispatches via 'hook-invoke'.
// 5. In slot mode: look up `slots[slot].component`, render it into the
// iframe body, push height back via ResizeObserver.
import { useEffect, useRef } from 'react';
import * as React from 'react';
import * as ReactDOM from 'react-dom/client';
import * as ReactJSXRuntime from 'react/jsx-runtime';
import type {
HostToSandbox,
SandboxToHost,
InitPayload,
BackgroundInit,
SlotInit,
} from './protocol';
import type { SlotName } from '../plugin-types';
// ─── Module-scope state ──────────────────────────────────────
interface PluginExports {
slots?: Record<string, { component: React.ComponentType<Record<string, unknown>>; shouldShow?: (ctx: unknown) => boolean; order?: number }>;
hooks?: Record<string, (...args: unknown[]) => unknown>;
activate?: (api: unknown) => void | Promise<void> | { dispose: () => void };
default?: unknown;
}
let parentWindow: Window | null = null;
let parentOrigin: string | null = null;
let pluginExports: PluginExports | null = null;
let mode: 'background' | 'slot' | null = null;
let slotName: SlotName | null = null;
let bootDone = false;
const pendingApi = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
const hookHandlers: Record<string, (...args: unknown[]) => unknown> = {};
function sendToHost(msg: SandboxToHost): void {
if (!parentWindow || !parentOrigin) return;
parentWindow.postMessage(msg, parentOrigin);
}
function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
// ─── Sandboxed API facade (calls flow to host via postMessage) ─
function callApi(method: string, args: unknown[]): Promise<unknown> {
const id = uid();
return new Promise((resolve, reject) => {
pendingApi.set(id, { resolve, reject });
sendToHost({ type: 'api-request', id, method, args });
// Reject after 30s to prevent unbounded promise leaks if the host hangs.
setTimeout(() => {
const entry = pendingApi.get(id);
if (!entry) return;
pendingApi.delete(id);
entry.reject(new Error(`API call ${method} timed out after 30s`));
}, 30_000);
});
}
function buildPluginApi(manifest: BackgroundInit['manifest']) {
return {
plugin: {
id: manifest.id,
version: manifest.version,
settings: { ...manifest.settings },
},
storage: {
get: (key: string) => callApi('storage.get', [key]),
set: (key: string, value: unknown) => callApi('storage.set', [key, value]),
remove: (key: string) => callApi('storage.remove', [key]),
keys: () => callApi('storage.keys', []),
},
http: {
post: (path: string, body: Record<string, unknown>) => callApi('http.post', [path, body]),
fetch: (url: string, init?: unknown) => callApi('http.fetch', [url, init]),
},
toast: {
success: (m: string) => { void callApi('toast.success', [m]); },
error: (m: string) => { void callApi('toast.error', [m]); },
info: (m: string) => { void callApi('toast.info', [m]); },
warning: (m: string) => { void callApi('toast.warning', [m]); },
},
admin: {
getConfig: (key: string) => callApi('admin.getConfig', [key]),
getAllConfig: () => callApi('admin.getAllConfig', []),
setConfig: (key: string, v: unknown) => callApi('admin.setConfig', [key, v]),
deleteConfig: (key: string) => callApi('admin.deleteConfig', [key]),
},
log: {
debug: (...a: unknown[]) => console.debug(`[plugin:${manifest.id}]`, ...a),
info: (...a: unknown[]) => console.info(`[plugin:${manifest.id}]`, ...a),
warn: (...a: unknown[]) => console.warn(`[plugin:${manifest.id}]`, ...a),
error: (...a: unknown[]) => console.error(`[plugin:${manifest.id}]`, ...a),
},
};
}
// ─── Bundle evaluation ───────────────────────────────────────
/**
* Resolve a bundler-emitted `require(name)` call inside the sandbox. Plugin
* bundlers should be configured to externalise React; the runtime provides
* those modules here. Anything else is refused — the sandbox has no Node-
* compatible module resolution and we don't want plugins probing globals.
*/
function makePluginRequire(): (name: string) => unknown {
const known: Record<string, unknown> = {
'react': React,
'react-dom': ReactDOM,
'react-dom/client': ReactDOM,
'react/jsx-runtime': ReactJSXRuntime,
'react/jsx-dev-runtime': ReactJSXRuntime,
};
return (name: string) => {
if (Object.prototype.hasOwnProperty.call(known, name)) return known[name];
throw new Error(`Plugin sandbox: module "${name}" is not available. Externalise it in your bundler or ship it bundled.`);
};
}
function evaluateBundle(code: string): PluginExports {
const mod: { exports: PluginExports } = { exports: {} };
const requireShim = makePluginRequire();
let fn: (...args: unknown[]) => void;
try {
fn = new Function(
'module', 'exports', 'require', 'React', 'ReactDOM', 'JsxRuntime', 'console',
code,
) as (...args: unknown[]) => void;
} catch (err) {
throw new Error(`Bundle parse error: ${(err as Error).message}`);
}
try {
fn(mod, mod.exports, requireShim, React, ReactDOM, ReactJSXRuntime, console);
} catch (err) {
throw new Error(`Bundle evaluation threw: ${(err as Error).message}`);
}
const exports = (mod.exports?.default ?? mod.exports) as PluginExports;
if (!exports || typeof exports !== 'object') {
throw new Error('Bundle did not produce module.exports object');
}
return exports;
}
// ─── Init flow ───────────────────────────────────────────────
async function bootBackground(payload: BackgroundInit): Promise<void> {
const exports = evaluateBundle(payload.code);
pluginExports = exports;
// Register hooks (each value must be a function).
const hookNames: string[] = [];
const hooks = exports.hooks ?? {};
for (const [name, handler] of Object.entries(hooks)) {
if (typeof handler === 'function') {
hookHandlers[name] = handler;
hookNames.push(name);
}
}
// Enumerate slot offers.
const slotInfo: Array<{ name: SlotName; hasShouldShow: boolean; order: number }> = [];
const slots = exports.slots ?? {};
for (const [name, def] of Object.entries(slots)) {
if (def && typeof def.component === 'function') {
slotInfo.push({
name: name as SlotName,
hasShouldShow: typeof def.shouldShow === 'function',
order: typeof def.order === 'number' ? def.order : 100,
});
}
}
// Side effects.
if (typeof exports.activate === 'function') {
await Promise.resolve(exports.activate(buildPluginApi(payload.manifest)));
}
sendToHost({ type: 'init-done', hooks: hookNames, slots: slotInfo });
}
function bootSlot(payload: SlotInit): void {
const exports = evaluateBundle(payload.code);
pluginExports = exports;
slotName = payload.slot;
const slotDef = exports.slots?.[payload.slot];
if (!slotDef || typeof slotDef.component !== 'function') {
throw new Error(`Plugin "${payload.pluginId}" does not export slots["${payload.slot}"].component`);
}
const rootEl = document.getElementById('plugin-sandbox-root');
if (!rootEl) throw new Error('Sandbox root element missing');
let currentProps: Record<string, unknown> = payload.extraProps;
const Component = slotDef.component;
const SlotShell = () => {
const wrapRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!wrapRef.current) return;
let lastHeight = -1;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const h = Math.ceil(entry.contentRect.height);
if (h !== lastHeight) {
lastHeight = h;
sendToHost({ type: 'slot-resize', height: h });
}
}
});
ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
return React.createElement('div', { ref: wrapRef }, React.createElement(Component, currentProps));
};
const reactRoot = ReactDOM.createRoot(rootEl);
reactRoot.render(React.createElement(SlotShell));
sendToHost({ type: 'init-done', hooks: [], slots: [] });
}
async function handleInit(payload: InitPayload): Promise<void> {
if (bootDone) return;
bootDone = true;
mode = payload.mode;
try {
if (payload.mode === 'background') {
await bootBackground(payload);
} else {
bootSlot(payload);
}
} catch (err) {
sendToHost({ type: 'init-error', error: (err as Error).message ?? String(err) });
}
}
// ─── Host message handler ────────────────────────────────────
function handleHostMessage(ev: MessageEvent): void {
// First inbound message pins source + origin. Reject everything else.
if (!parentWindow) {
if (!ev.source || ev.source === window) return;
parentWindow = ev.source as Window;
parentOrigin = ev.origin || null;
}
if (ev.source !== parentWindow) return;
if (parentOrigin && ev.origin !== parentOrigin) return;
const msg = ev.data as HostToSandbox;
if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return;
switch (msg.type) {
case 'init':
void handleInit(msg.payload);
break;
case 'api-response': {
const pending = pendingApi.get(msg.id);
if (!pending) return;
pendingApi.delete(msg.id);
if (msg.ok) pending.resolve(msg.result);
else pending.reject(new Error(msg.error ?? 'api error'));
break;
}
case 'hook-invoke': {
const handler = hookHandlers[msg.hookName];
if (!handler) {
sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: `no handler for ${msg.hookName}` });
return;
}
try {
const result = handler(...(msg.args ?? []));
Promise.resolve(result).then(
(v) => sendToHost({ type: 'hook-result', id: msg.id, ok: true, result: v }),
(e) => sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: (e as Error).message ?? String(e) }),
);
} catch (err) {
sendToHost({ type: 'hook-result', id: msg.id, ok: false, error: (err as Error).message });
}
break;
}
case 'slot-should-show': {
// Resolved by the background instance for any slot it offers.
const slotDef = pluginExports?.slots?.[msg.slot];
let show = true;
try {
if (slotDef && typeof slotDef.shouldShow === 'function') {
show = !!slotDef.shouldShow(msg.context);
}
} catch {
show = false;
}
sendToHost({ type: 'slot-should-show-result', id: msg.id, show });
break;
}
case 'locale-change':
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = msg.locale;
break;
case 'props-update':
// Phase-2: would push updates into the slot shell. Currently the slot
// iframe is torn down and recreated when props change at the host.
break;
}
}
// ─── React entry ─────────────────────────────────────────────
export function SandboxRuntime(): React.JSX.Element {
const inited = useRef(false);
useEffect(() => {
if (inited.current) return;
inited.current = true;
window.addEventListener('message', handleHostMessage);
// Initial ping. We don't know parent origin yet, so '*' is required.
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'sandbox-ready' } satisfies SandboxToHost, '*');
}
return () => {
window.removeEventListener('message', handleHostMessage);
};
}, []);
return <div id="plugin-sandbox-root" />;
}
// Suppress unused-variable warning when `mode` is only read for debugging.
void mode;
void slotName;
+16 -7
View File
@@ -74,22 +74,31 @@ 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/");
const scriptSrc = isDev
? `'self' 'nonce-${nonce}' 'unsafe-eval' blob:`
: `'self' 'nonce-${nonce}' blob:`;
const scriptSrc = isSandboxPath
? `'self' 'nonce-${nonce}' 'unsafe-eval'`
: isDev
? `'self' 'nonce-${nonce}' 'unsafe-eval'`
: `'self' 'nonce-${nonce}'`;
const connectSrc = isDev ? `'self' http: https: ws: wss:` : `'self' https:`;
const frameAncestors = process.env.ALLOWED_FRAME_ANCESTORS?.trim() || "'none'";
const frameAncestors = isSandboxPath
? `'self'`
: process.env.ALLOWED_FRAME_ANCESTORS?.trim() || "'none'";
// Plugins may declare iframe origins they need (e.g. for embedded video).
// Each origin is validated at install time and re-validated here.
const pluginFrameOrigins = await getEnabledPluginFrameOrigins();
const frameSrc =
pluginFrameOrigins.length > 0
? `frame-src 'self' blob: ${pluginFrameOrigins.join(" ")}`
: `frame-src 'self' blob:`;
? `frame-src 'self' ${pluginFrameOrigins.join(" ")}`
: `frame-src 'self'`;
const csp = [
`default-src 'self'`,
@@ -99,7 +108,7 @@ export async function proxy(request: NextRequest) {
`font-src 'self'`,
`connect-src ${connectSrc}`,
frameSrc,
`object-src 'self' blob:`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors ${frameAncestors}`,