feat: lock down plugin runtime in sandbox + signing + approval

This commit is contained in:
Linus Rath
2026-05-18 12:44:23 +02:00
parent 088810bd20
commit 48aa607b56
22 changed files with 986 additions and 21 deletions
+90
View File
@@ -0,0 +1,90 @@
// Client-side Ed25519 verification for plugin bundles.
//
// On boot the loader fetches the host's public key from
// `/api/plugin-signing-pubkey`. Each `/api/admin/plugins/[id]/bundle` response
// includes the signature as the `X-Bundle-Signature` header. Before evaluating
// a bundle the loader verifies the signature; mismatch refuses the load.
//
// User-installed plugins (uploaded via the file picker, no server hop) have
// no signature — verification is skipped for those, since the user is
// installing their own code. Verification kicks in for server-managed
// bundles only (the `managed: true` flag on `InstalledPlugin`).
let cachedPubKey: CryptoKey | null = null;
let pubKeyPromise: Promise<CryptoKey | null> | null = null;
async function importEd25519PublicKey(raw: Uint8Array): Promise<CryptoKey | null> {
if (typeof crypto === 'undefined' || !crypto.subtle) return null;
try {
// Browser Web Crypto supports Ed25519 via `name: 'Ed25519'` (no hash).
return await crypto.subtle.importKey('raw', raw.buffer.slice(0) as ArrayBuffer, { name: 'Ed25519' }, false, ['verify']);
} catch (err) {
console.warn('[plugin-signing] Web Crypto Ed25519 import failed', err);
return null;
}
}
async function fetchPublicKey(): Promise<CryptoKey | null> {
try {
const res = await fetch('/api/plugin-signing-pubkey', { credentials: 'same-origin' });
if (!res.ok) return null;
const data = await res.json() as { algorithm?: string; publicKey?: string };
if (data.algorithm !== 'ed25519' || typeof data.publicKey !== 'string') return null;
const raw = base64ToBytes(data.publicKey);
if (raw.length !== 32) return null;
return importEd25519PublicKey(raw);
} catch (err) {
console.warn('[plugin-signing] could not fetch public key', err);
return null;
}
}
export async function getPluginSigningKey(): Promise<CryptoKey | null> {
if (cachedPubKey) return cachedPubKey;
if (!pubKeyPromise) {
pubKeyPromise = fetchPublicKey().then((k) => { cachedPubKey = k; return k; });
}
return pubKeyPromise;
}
/** Force a refresh on next access (e.g. after key rotation). */
export function invalidatePluginSigningKeyCache(): void {
cachedPubKey = null;
pubKeyPromise = null;
}
/**
* Verify a base64 Ed25519 signature against the bundle bytes. Returns false
* on any failure (missing key, invalid encoding, signature mismatch). Never
* throws.
*/
export async function verifySignature(code: string, signatureB64: string): Promise<boolean> {
if (!signatureB64) return false;
const key = await getPluginSigningKey();
if (!key) return false;
let signature: Uint8Array;
try {
signature = base64ToBytes(signatureB64);
} catch {
return false;
}
if (signature.length !== 64) return false;
const data = new TextEncoder().encode(code);
try {
return await crypto.subtle.verify(
{ name: 'Ed25519' },
key,
signature.buffer.slice(0) as ArrayBuffer,
data.buffer.slice(0) as ArrayBuffer,
);
} catch {
return false;
}
}
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
+28 -2
View File
@@ -100,7 +100,24 @@ function storageKeys(pluginId: string): string[] {
// ─── http.post (same-origin /api/*) ───────────────────────────
async function doHttpPost(path: string, body: unknown): Promise<{ ok: boolean; status: number; data: unknown }> {
/**
* Returns true iff `path` is permitted by the plugin's `apiPostPaths`
* allowlist. Entries are either exact paths (must equal `path`) or prefixes
* that end with `/` (`path` must start with the entry).
*/
function isApiPostPathAllowed(path: string, allowlist: readonly string[]): boolean {
for (const entry of allowlist) {
if (typeof entry !== 'string' || !entry.startsWith('/api/')) continue;
if (entry.endsWith('/')) {
if (path === entry || path.startsWith(entry)) return true;
} else if (path === entry) {
return true;
}
}
return false;
}
async function doHttpPost(plugin: InstalledPlugin, 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/');
}
@@ -108,6 +125,15 @@ async function doHttpPost(path: string, body: unknown): Promise<{ ok: boolean; s
if (url.origin !== window.location.origin) {
throw new Error('path must resolve to the same origin');
}
// Per-plugin path allow-list. Comparison is on the pathname only (query
// strings don't widen the surface, so we ignore them here).
const allow = plugin.apiPostPaths ?? [];
if (allow.length === 0) {
throw new Error(`Plugin "${plugin.id}" has no apiPostPaths declared`);
}
if (!isApiPostPathAllowed(url.pathname, allow)) {
throw new Error(`Path ${url.pathname} not in plugin apiPostPaths allowlist`);
}
const { client } = useAuthStore.getState();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (client) {
@@ -230,7 +256,7 @@ export async function dispatchApiCall(
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.post': return doHttpPost(plugin, args[0] as string, args[1]);
case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined);
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
+2 -1
View File
@@ -72,6 +72,7 @@ export interface SlotOptions {
export interface InitDoneInfo {
hooks: string[];
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number }>;
shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>;
}
// ─── Sandbox instance ────────────────────────────────────────
@@ -169,7 +170,7 @@ export class SandboxInstance {
return;
case 'init-done':
this.resolveInit({ hooks: msg.hooks, slots: msg.slots });
this.resolveInit({ hooks: msg.hooks, slots: msg.slots, shortcuts: msg.shortcuts ?? [] });
return;
case 'init-error':
+8 -1
View File
@@ -16,6 +16,7 @@ import { verifyBundle } from './bundle-integrity';
import { createBackgroundInstance } from './host-bridge';
import { register as registerActive, deregister as deregisterActive } from './registry';
import { cancelPluginDialogs } from './host-api';
import { registerShortcuts } from './shortcuts';
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
@@ -80,9 +81,11 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
const info = await background.initPromise;
// Wire hook proxies: every hookName the plugin registered gets a HookBus
// entry whose handler dispatches into the sandbox.
// entry whose handler dispatches into the sandbox. `shortcut:<id>` hooks
// are dispatched by the keyboard module separately and don't have a bus.
const hookDisposables: Disposable[] = [];
for (const hookName of info.hooks) {
if (hookName.startsWith('shortcut:')) continue;
const bus = HOOK_BUSES[hookName];
if (!bus) {
console.warn(`[plugin-sandbox] Plugin "${plugin.id}" registered unknown hook "${hookName}"`);
@@ -99,6 +102,10 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
hookDisposables.push(bus.register(plugin.id, proxy as (...a: unknown[]) => unknown));
}
// Install plugin-declared keyboard shortcuts.
const shortcutDispose = registerShortcuts(background, info.shortcuts ?? []);
hookDisposables.push({ dispose: shortcutDispose });
registerActive({
plugin,
code,
+6
View File
@@ -73,6 +73,12 @@ export interface InitDoneMsg {
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 }>;
/**
* Keyboard shortcuts the plugin declares. The host installs a global
* keydown listener that dispatches to the `shortcut:<id>` hook when the
* combo matches. `keys` is a `+`-separated string like "Ctrl+Shift+L".
*/
shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>;
}
export interface InitErrorMsg { type: 'init-error'; error: string; }
+28 -2
View File
@@ -34,6 +34,16 @@ import type { SlotName } from '../plugin-types';
interface PluginExports {
slots?: Record<string, { component: React.ComponentType<Record<string, unknown>>; shouldShow?: (ctx: unknown) => boolean; order?: number }>;
hooks?: Record<string, (...args: unknown[]) => unknown>;
/**
* Keyboard shortcut bindings. Each entry's `handler` is registered as a
* hook named `shortcut:<id>` so the host's keydown dispatcher can fire it.
*/
shortcuts?: Record<string, {
keys: string;
label: string;
category?: string;
handler: () => void | Promise<void>;
}>;
activate?: (api: unknown) => void | Promise<void> | { dispose: () => void };
default?: unknown;
}
@@ -243,12 +253,28 @@ async function bootBackground(payload: BackgroundInit): Promise<void> {
}
}
// Shortcuts: register each handler as a 'shortcut:<id>' hook so the host's
// global keydown dispatcher can invoke it.
const shortcutInfo: Array<{ id: string; keys: string; label: string; category?: string }> = [];
const shortcuts = exports.shortcuts ?? {};
for (const [id, def] of Object.entries(shortcuts)) {
if (!def || typeof def.handler !== 'function' || typeof def.keys !== 'string') continue;
hookHandlers[`shortcut:${id}`] = def.handler as (...args: unknown[]) => unknown;
hookNames.push(`shortcut:${id}`);
shortcutInfo.push({
id,
keys: def.keys,
label: typeof def.label === 'string' ? def.label : id,
category: typeof def.category === 'string' ? def.category : undefined,
});
}
// Side effects.
if (typeof exports.activate === 'function') {
await Promise.resolve(exports.activate(api));
}
sendToHost({ type: 'init-done', hooks: hookNames, slots: slotInfo });
sendToHost({ type: 'init-done', hooks: hookNames, slots: slotInfo, shortcuts: shortcutInfo });
}
function bootSlot(payload: SlotInit): void {
@@ -305,7 +331,7 @@ function bootSlot(payload: SlotInit): void {
const reactRoot = ReactDOM.createRoot(rootEl);
reactRoot.render(React.createElement(SlotShell));
sendToHost({ type: 'init-done', hooks: [], slots: [] });
sendToHost({ type: 'init-done', hooks: [], slots: [], shortcuts: [] });
}
// Populated by bootSlot — receives `props-update` messages.
+132
View File
@@ -0,0 +1,132 @@
// Plugin shortcut dispatcher.
//
// Each enabled plugin declares zero-or-more keyboard shortcuts via its
// `shortcuts` export. On init the host registers each binding here. A single
// window keydown listener matches keys against the active bindings and
// dispatches via `instance.invokeHook('shortcut:<id>', [])`.
//
// The listener ignores events when an editable element has focus, matching
// the convention in `use-keyboard-shortcuts.ts`.
import type { SandboxInstance } from './host-bridge';
interface Binding {
pluginId: string;
shortcutId: string;
keys: string; // "Ctrl+Shift+L"
label: string;
category?: string;
invoke: () => Promise<void>;
}
interface NormalisedCombo {
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
key: string;
}
const bindings = new Map<string, Binding>(); // key: `${pluginId}:${shortcutId}`
let listenerInstalled = false;
function normaliseCombo(combo: string): NormalisedCombo | null {
if (typeof combo !== 'string') return null;
const parts = combo.split('+').map(p => p.trim()).filter(Boolean);
if (parts.length === 0) return null;
let ctrl = false, shift = false, alt = false, meta = false;
let key = '';
for (const p of parts) {
const lower = p.toLowerCase();
if (lower === 'ctrl' || lower === 'control') ctrl = true;
else if (lower === 'shift') shift = true;
else if (lower === 'alt' || lower === 'option') alt = true;
else if (lower === 'meta' || lower === 'cmd' || lower === 'command') meta = true;
else key = lower;
}
if (!key) return null;
return { ctrl, shift, alt, meta, key };
}
function eventMatches(ev: KeyboardEvent, combo: NormalisedCombo): boolean {
if (combo.ctrl !== (ev.ctrlKey || ev.metaKey ? ev.ctrlKey : false)) {
// Treat Ctrl and Cmd as equivalent: a binding declaring Ctrl matches a
// Cmd press on macOS.
if (combo.ctrl) {
if (!(ev.ctrlKey || ev.metaKey)) return false;
} else if (ev.ctrlKey) return false;
}
if (combo.shift !== ev.shiftKey) return false;
if (combo.alt !== ev.altKey) return false;
if (!combo.ctrl && combo.meta !== ev.metaKey) return false;
return ev.key.toLowerCase() === combo.key;
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
if (target.isContentEditable) return true;
return false;
}
function onKeyDown(ev: KeyboardEvent): void {
if (isEditableTarget(ev.target)) return;
if (bindings.size === 0) return;
for (const binding of bindings.values()) {
const combo = normaliseCombo(binding.keys);
if (!combo) continue;
if (eventMatches(ev, combo)) {
ev.preventDefault();
ev.stopPropagation();
void binding.invoke();
return;
}
}
}
function ensureListener(): void {
if (listenerInstalled || typeof window === 'undefined') return;
listenerInstalled = true;
window.addEventListener('keydown', onKeyDown, true);
}
export function registerShortcuts(
instance: SandboxInstance,
shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>,
): () => void {
ensureListener();
const keys: string[] = [];
for (const sc of shortcuts) {
const key = `${instance.pluginId}:${sc.id}`;
bindings.set(key, {
pluginId: instance.pluginId,
shortcutId: sc.id,
keys: sc.keys,
label: sc.label,
category: sc.category,
invoke: async () => {
try {
await instance.invokeHook(`shortcut:${sc.id}`, []);
} catch {
/* hook tracker already logs */
}
},
});
keys.push(key);
}
return () => {
for (const k of keys) bindings.delete(k);
};
}
/** Snapshot of currently active shortcuts. Used by the help modal. */
export function listShortcuts(): Array<{ pluginId: string; id: string; keys: string; label: string; category?: string }> {
return [...bindings.values()].map(b => ({
pluginId: b.pluginId,
id: b.shortcutId,
keys: b.keys,
label: b.label,
category: b.category,
}));
}