From 9f312aa556471fc84df64c70ca0697f8689006da Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 18 May 2026 10:50:49 +0200 Subject: [PATCH] feat: sandbox plugins in null-origin iframes with postMessage RPC --- app/plugin-sandbox/layout.tsx | 17 ++ app/plugin-sandbox/page.tsx | 7 + components/email/email-viewer.tsx | 6 +- components/plugins/plugin-iframe-slot.tsx | 74 +++++ components/plugins/plugin-slot.tsx | 23 +- hooks/use-plugin-slot-offers.ts | 17 ++ lib/plugin-loader.ts | 208 ++++--------- lib/plugin-sandbox/bundle-integrity.ts | 57 ++++ lib/plugin-sandbox/host-api.ts | 234 +++++++++++++++ lib/plugin-sandbox/host-bridge.ts | 260 ++++++++++++++++ lib/plugin-sandbox/loader.ts | 158 ++++++++++ lib/plugin-sandbox/protocol.ts | 159 ++++++++++ lib/plugin-sandbox/registry.ts | 66 ++++ lib/plugin-sandbox/runtime.tsx | 348 ++++++++++++++++++++++ proxy.ts | 23 +- 15 files changed, 1481 insertions(+), 176 deletions(-) create mode 100644 app/plugin-sandbox/layout.tsx create mode 100644 app/plugin-sandbox/page.tsx create mode 100644 components/plugins/plugin-iframe-slot.tsx create mode 100644 hooks/use-plugin-slot-offers.ts create mode 100644 lib/plugin-sandbox/bundle-integrity.ts create mode 100644 lib/plugin-sandbox/host-api.ts create mode 100644 lib/plugin-sandbox/host-bridge.ts create mode 100644 lib/plugin-sandbox/loader.ts create mode 100644 lib/plugin-sandbox/protocol.ts create mode 100644 lib/plugin-sandbox/registry.ts create mode 100644 lib/plugin-sandbox/runtime.tsx diff --git a/app/plugin-sandbox/layout.tsx b/app/plugin-sandbox/layout.tsx new file mode 100644 index 00000000..8c1f20d4 --- /dev/null +++ b/app/plugin-sandbox/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/app/plugin-sandbox/page.tsx b/app/plugin-sandbox/page.tsx new file mode 100644 index 00000000..6ddfd078 --- /dev/null +++ b/app/plugin-sandbox/page.tsx @@ -0,0 +1,7 @@ +import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime'; + +export const dynamic = 'force-static'; + +export default function PluginSandboxPage() { + return ; +} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 147b5f66..90a839a6 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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); diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx new file mode 100644 index 00000000..b95f3166 --- /dev/null +++ b/components/plugins/plugin-iframe-slot.tsx @@ -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
. + +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; +} + +export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) { + const wrapperRef = useRef(null); + const instanceRef = useRef(null); + const [height, setHeight] = useState(0); + // null = pending, true/false = decided + const [show, setShow] = useState(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
; +} diff --git a/components/plugins/plugin-slot.tsx b/components/plugins/plugin-slot.tsx index 63489412..ea00587a 100644 --- a/components/plugins/plugin-slot.tsx +++ b/components/plugins/plugin-slot.tsx @@ -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 (
- {registrations.map((reg, i) => ( - ( + ))} diff --git a/hooks/use-plugin-slot-offers.ts b/hooks/use-plugin-slot-offers.ts new file mode 100644 index 00000000..2501cc05 --- /dev/null +++ b/hooks/use-plugin-slot-offers.ts @@ -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), + () => [], + ); +} diff --git a/lib/plugin-loader.ts b/lib/plugin-loader.ts index b7f9d219..468af2fa 100644 --- a/lib/plugin-loader.ts +++ b/lib/plugin-loader.ts @@ -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(); - -// --- 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 { - 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 { - // 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 }; diff --git a/lib/plugin-sandbox/bundle-integrity.ts b/lib/plugin-sandbox/bundle-integrity.ts new file mode 100644 index 00000000..484c55ab --- /dev/null +++ b/lib/plugin-sandbox/bundle-integrity.ts @@ -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 { + // 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 { + 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; +} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts new file mode 100644 index 00000000..ba208fde --- /dev/null +++ b/lib/plugin-sandbox/host-api.ts @@ -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 = { + // 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 = { '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; + 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 = {}; + 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 = {}; + 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> { + 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 { + const all = await adminGetAll(pluginId); + return all[key] ?? null; +} +async function adminSet(pluginId: string, key: string, value: unknown): Promise { + 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 { + 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 { + // 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}"`); + } +} diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts new file mode 100644 index 00000000..0f052dd6 --- /dev/null +++ b/lib/plugin-sandbox/host-bridge.ts @@ -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; + /** 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; + initPromise: Promise; + + 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 void; reject: (e: Error) => void }>(); + private pendingShouldShow = new Map 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((res) => { this.resolveReady = res; }); + this.initPromise = new Promise((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 { + if (this.destroyed) return Promise.reject(new Error('sandbox destroyed')); + const id = uid(); + const p = new Promise((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 { + if (this.destroyed) return Promise.resolve(false); + const id = uid(); + const p = new Promise((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): 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); +} diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts new file mode 100644 index 00000000..b60fc8b4 --- /dev/null +++ b/lib/plugin-sandbox/loader.ts @@ -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 = 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; + +// ─── 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 { + // 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 { + 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 { + 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'; diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts new file mode 100644 index 00000000..1da6741f --- /dev/null +++ b/lib/plugin-sandbox/protocol.ts @@ -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; + locales?: Record>; + 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; + 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; } + +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]; diff --git a/lib/plugin-sandbox/registry.ts b/lib/plugin-sandbox/registry.ts new file mode 100644 index 00000000..85870543 --- /dev/null +++ b/lib/plugin-sandbox/registry.ts @@ -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(); +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); }; +} diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx new file mode 100644 index 00000000..50c503a0 --- /dev/null +++ b/lib/plugin-sandbox/runtime.tsx @@ -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>; shouldShow?: (ctx: unknown) => boolean; order?: number }>; + hooks?: Record unknown>; + activate?: (api: unknown) => void | Promise | { 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 void; reject: (err: Error) => void }>(); +const hookHandlers: Record 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 { + 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) => 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 = { + '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 { + 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 = payload.extraProps; + const Component = slotDef.component; + + const SlotShell = () => { + const wrapRef = React.useRef(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 { + 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
; +} + +// Suppress unused-variable warning when `mode` is only read for debugging. +void mode; +void slotName; diff --git a/proxy.ts b/proxy.ts index 135a6f32..99093399 100644 --- a/proxy.ts +++ b/proxy.ts @@ -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}`,