From 48aa607b56c7ca5a57b4ba2ea61f9013c6db9217 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 18 May 2026 12:44:23 +0200 Subject: [PATCH] feat: lock down plugin runtime in sandbox + signing + approval --- app/admin/_tabs/plugin-config-panel.tsx | 23 +++ app/api/admin/marketplace/route.ts | 18 ++- app/api/admin/plugin-approvals/route.ts | 85 ++++++++++ app/api/admin/plugins/[id]/bundle/route.ts | 25 ++- app/api/admin/plugins/route.ts | 7 +- app/api/plugin-approval-status/route.ts | 89 +++++++++++ app/api/plugin-signing-pubkey/route.ts | 27 ++++ app/api/plugins/route.ts | 2 + lib/admin/csp-frame-origins.ts | 30 ++++ lib/admin/plugin-approvals.ts | 175 +++++++++++++++++++++ lib/admin/plugin-dev.ts | 4 +- lib/admin/plugin-registry.ts | 5 + lib/admin/plugin-signing.ts | 98 ++++++++++++ lib/plugin-sandbox/bundle-signing.ts | 90 +++++++++++ lib/plugin-sandbox/host-api.ts | 30 +++- lib/plugin-sandbox/host-bridge.ts | 3 +- lib/plugin-sandbox/loader.ts | 9 +- lib/plugin-sandbox/protocol.ts | 6 + lib/plugin-sandbox/runtime.tsx | 30 +++- lib/plugin-sandbox/shortcuts.ts | 132 ++++++++++++++++ lib/plugin-types.ts | 14 ++ stores/plugin-store.ts | 105 ++++++++++++- 22 files changed, 986 insertions(+), 21 deletions(-) create mode 100644 app/api/admin/plugin-approvals/route.ts create mode 100644 app/api/plugin-approval-status/route.ts create mode 100644 app/api/plugin-signing-pubkey/route.ts create mode 100644 lib/admin/plugin-approvals.ts create mode 100644 lib/admin/plugin-signing.ts create mode 100644 lib/plugin-sandbox/bundle-signing.ts create mode 100644 lib/plugin-sandbox/shortcuts.ts diff --git a/app/admin/_tabs/plugin-config-panel.tsx b/app/admin/_tabs/plugin-config-panel.tsx index e3bea8b4..70e83cb7 100644 --- a/app/admin/_tabs/plugin-config-panel.tsx +++ b/app/admin/_tabs/plugin-config-panel.tsx @@ -3,6 +3,8 @@ import { useEffect, useState } from 'react'; import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; +import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers'; +import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot'; interface ConfigField { type: 'string' | 'secret' | 'boolean' | 'number' | 'select'; @@ -286,6 +288,27 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {

This plugin does not declare any configuration settings.

)} + + + + ); +} + +/** + * Renders the plugin's own `admin-plugin-page` slot, if the plugin offers + * one. Sandboxed plugins ship a React component under `slots['admin-plugin-page']` + * and the host gives it a dedicated iframe inside the admin panel. + */ +function PluginAdminSection({ pluginId }: { pluginId: string }) { + const offers = usePluginSlotOffers('admin-plugin-page'); + const offer = offers.find((o) => o.pluginId === pluginId); + if (!offer) return null; + return ( +
+
+ Plugin admin panel +
+
); } diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 1d075732..feb649ac 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -13,6 +13,7 @@ import { import { sanitizeFrameOrigins, sanitizeHttpOrigins, + sanitizeApiPostPaths, invalidateFrameOriginsCache, } from '@/lib/admin/csp-frame-origins'; import JSZip from 'jszip'; @@ -266,6 +267,18 @@ export async function POST(request: NextRequest) { ); } + const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths); + const droppedApiPostPaths = Array.isArray(manifest.apiPostPaths) + ? (manifest.apiPostPaths as unknown[]).filter( + (v) => typeof v !== 'string' || !declaredApiPostPaths.includes(v), + ) + : []; + if (droppedApiPostPaths.length > 0) { + warnings.push( + `Ignored invalid apiPostPaths: ${droppedApiPostPaths.join(', ')}`, + ); + } + const plugin: ServerPlugin = { id: (manifest.id as string) || slug, name: (manifest.name as string) || slug, @@ -290,11 +303,14 @@ export async function POST(request: NextRequest) { ...(declaredHttpOrigins.length > 0 ? { httpOrigins: declaredHttpOrigins } : {}), + ...(declaredApiPostPaths.length > 0 + ? { apiPostPaths: declaredApiPostPaths } + : {}), }; await savePlugin(plugin, code); invalidateFrameOriginsCache(); - await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip); + await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip); return NextResponse.json({ success: true, plugin, warnings }); } diff --git a/app/api/admin/plugin-approvals/route.ts b/app/api/admin/plugin-approvals/route.ts new file mode 100644 index 00000000..5de7f2f9 --- /dev/null +++ b/app/api/admin/plugin-approvals/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; +import { auditLog } from '@/lib/admin/audit'; +import { logger } from '@/lib/logger'; +import { listApprovals, decideApproval, revokeApproval } from '@/lib/admin/plugin-approvals'; + +/** + * Admin-protected CRUD for the per-(pluginId, bundleHash) approval table. + * + * GET /api/admin/plugin-approvals → list all entries + * POST /api/admin/plugin-approvals → { pluginId, bundleHash, decision: 'approved'|'denied' } + * DELETE /api/admin/plugin-approvals?pluginId=…&bundleHash=… → revoke + */ + +function isValidId(s: unknown): s is string { + return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64; +} +function isValidHash(s: unknown): s is string { + return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s); +} + +export async function GET() { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + const entries = await listApprovals(); + return NextResponse.json({ entries }, { headers: { 'Cache-Control': 'no-store' } }); + } catch (err) { + logger.error('plugin-approvals GET', { error: err instanceof Error ? err.message : String(err) }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + // AdminSessionPayload carries only role/iat/exp; we use a stable label + // for the audit trail rather than a per-user identity. + const adminUser = 'admin'; + void result; + const ip = getClientIP(request); + + let body: unknown; + try { body = await request.json(); } catch { body = null; } + const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; decision?: unknown }; + if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) { + return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 }); + } + if (b.decision !== 'approved' && b.decision !== 'denied') { + return NextResponse.json({ error: 'decision must be "approved" or "denied"' }, { status: 400 }); + } + + const entry = await decideApproval(b.pluginId, b.bundleHash, b.decision, adminUser); + await auditLog('plugin.approval', { pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision: entry.status }, ip); + return NextResponse.json({ entry }); + } catch (err) { + logger.error('plugin-approvals POST', { error: err instanceof Error ? err.message : String(err) }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest) { + try { + const result = await requireAdminAuth(); + if ('error' in result) return result.error; + // AdminSessionPayload carries only role/iat/exp; we use a stable label + // for the audit trail rather than a per-user identity. + const adminUser = 'admin'; + void result; + const ip = getClientIP(request); + + const pluginId = request.nextUrl.searchParams.get('pluginId'); + const bundleHash = request.nextUrl.searchParams.get('bundleHash'); + if (!isValidId(pluginId) || !isValidHash(bundleHash)) { + return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 }); + } + await revokeApproval(pluginId, bundleHash); + await auditLog('plugin.approval.revoke', { pluginId, bundleHash, by: adminUser }, ip); + return NextResponse.json({ ok: true }); + } catch (err) { + logger.error('plugin-approvals DELETE', { error: err instanceof Error ? err.message : String(err) }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/admin/plugins/[id]/bundle/route.ts b/app/api/admin/plugins/[id]/bundle/route.ts index bf8db9e5..f2a4fc68 100644 --- a/app/api/admin/plugins/[id]/bundle/route.ts +++ b/app/api/admin/plugins/[id]/bundle/route.ts @@ -1,6 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry'; import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev'; +import { signBytes } from '@/lib/admin/plugin-signing'; + +async function safeSign(code: string): Promise { + try { return await signBytes(code); } catch { return null; } +} /** * GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle @@ -25,14 +30,15 @@ export async function GET( const devEntry = await getDevPlugin(id); if (devEntry) { const code = await readDevBundle(devEntry); - return new NextResponse(code, { - headers: { - 'Content-Type': 'application/javascript; charset=utf-8', - 'Cache-Control': 'no-store', - 'ETag': `"${devEntry.plugin.bundleHash}"`, - 'Content-Length': String(Buffer.byteLength(code, 'utf-8')), - }, - }); + const signature = await safeSign(code); + const headers: Record = { + 'Content-Type': 'application/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + 'ETag': `"${devEntry.plugin.bundleHash}"`, + 'Content-Length': String(Buffer.byteLength(code, 'utf-8')), + }; + if (signature) headers['X-Bundle-Signature'] = signature; + return new NextResponse(code, { headers }); } const plugin = await getPlugin(id); @@ -59,6 +65,9 @@ export async function GET( }; if (etag) headers['ETag'] = etag; + const signature = await safeSign(code); + if (signature) headers['X-Bundle-Signature'] = signature; + if (etag && request.headers.get('if-none-match') === etag) { return new NextResponse(null, { status: 304, headers }); } diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index c14003cb..e70bae49 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -12,6 +12,7 @@ import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { sanitizeFrameOrigins, sanitizeHttpOrigins, + sanitizeApiPostPaths, invalidateFrameOriginsCache, } from '@/lib/admin/csp-frame-origins'; @@ -172,6 +173,7 @@ export async function POST(request: NextRequest) { const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins); + const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths); const now = new Date().toISOString(); const plugin: ServerPlugin = { @@ -196,13 +198,16 @@ export async function POST(request: NextRequest) { ...(declaredHttpOrigins.length > 0 ? { httpOrigins: declaredHttpOrigins } : {}), + ...(declaredApiPostPaths.length > 0 + ? { apiPostPaths: declaredApiPostPaths } + : {}), installedAt: now, updatedAt: now, }; await savePlugin(plugin, code); invalidateFrameOriginsCache(); - await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip); + await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip); return NextResponse.json({ plugin }); } catch (error) { diff --git a/app/api/plugin-approval-status/route.ts b/app/api/plugin-approval-status/route.ts new file mode 100644 index 00000000..21c5f42e --- /dev/null +++ b/app/api/plugin-approval-status/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { decryptSession } from '@/lib/auth/crypto'; +import { sessionCookieName } from '@/lib/auth/session-cookie'; +import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; +import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; +import { logger } from '@/lib/logger'; +import { getApprovalStatus, requestApproval, type ApprovalEntry } from '@/lib/admin/plugin-approvals'; + +/** + * GET /api/plugin-approval-status?pluginId=X&bundleHash=Y + * + * Any logged-in user may query the server-side approval state for a plugin + * they want to enable. The client uses this BEFORE running `enablePlugin` + * when the `requirePluginApproval` policy is set. + * + * POST same path with body `{ pluginId, bundleHash, manifest }` creates a + * pending approval entry (or returns the existing one). + */ + +async function resolveUsername(): Promise { + const cookieStore = await cookies(); + for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) { + const token = cookieStore.get(sessionCookieName(slot))?.value; + if (token) { + const sess = decryptSession(token); + if (sess?.username) return sess.username; + } + const ctx = readStalwartAuthContextFromStore(cookieStore, slot); + if (ctx?.username) return ctx.username; + } + return null; +} + +function isValidId(s: unknown): s is string { + return typeof s === 'string' && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(s) && s.length <= 64; +} +function isValidHash(s: unknown): s is string { + return typeof s === 'string' && /^[a-f0-9]{16,128}$/i.test(s); +} + +export async function GET(request: NextRequest) { + try { + const username = await resolveUsername(); + if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 }); + + const pluginId = request.nextUrl.searchParams.get('pluginId'); + const bundleHash = request.nextUrl.searchParams.get('bundleHash'); + if (!isValidId(pluginId) || !isValidHash(bundleHash)) { + return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 }); + } + const status = await getApprovalStatus(pluginId, bundleHash); + return NextResponse.json(status, { headers: { 'Cache-Control': 'no-store' } }); + } catch (err) { + logger.error('plugin-approval-status GET', { error: err instanceof Error ? err.message : String(err) }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const username = await resolveUsername(); + if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 }); + + let body: unknown; + try { body = await request.json(); } catch { body = null; } + const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; manifest?: unknown }; + if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) { + return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 }); + } + + const m = (b.manifest ?? {}) as Record; + const manifest: ApprovalEntry['manifest'] = { + name: typeof m.name === 'string' ? m.name.slice(0, 200) : undefined, + version: typeof m.version === 'string' ? m.version.slice(0, 64) : undefined, + author: typeof m.author === 'string' ? m.author.slice(0, 200) : undefined, + description: typeof m.description === 'string' ? m.description.slice(0, 500) : undefined, + permissions: Array.isArray(m.permissions) ? (m.permissions as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 50) : undefined, + httpOrigins: Array.isArray(m.httpOrigins) ? (m.httpOrigins as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined, + apiPostPaths: Array.isArray(m.apiPostPaths) ? (m.apiPostPaths as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined, + }; + + const entry = await requestApproval(b.pluginId as string, b.bundleHash as string, manifest, username); + return NextResponse.json({ status: entry.status, requestedAt: entry.requestedAt, decidedAt: entry.decidedAt }); + } catch (err) { + logger.error('plugin-approval-status POST', { error: err instanceof Error ? err.message : String(err) }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/plugin-signing-pubkey/route.ts b/app/api/plugin-signing-pubkey/route.ts new file mode 100644 index 00000000..8c49739e --- /dev/null +++ b/app/api/plugin-signing-pubkey/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server'; +import { getPublicKeyBase64 } from '@/lib/admin/plugin-signing'; +import { logger } from '@/lib/logger'; + +/** + * GET /api/plugin-signing-pubkey + * + * Returns the host's Ed25519 public key (base64-encoded raw 32 bytes) so the + * sandboxed plugin loader can verify bundle signatures before evaluation. + * Public — every logged-in user needs to fetch it on app boot. + * + * The response is long-cache-eligible (the key rotates only when an operator + * deletes the on-disk PEM), but we keep it `no-store` for simplicity. The + * client caches the result in memory for the lifetime of the page. + */ +export async function GET() { + try { + const publicKey = await getPublicKeyBase64(); + return NextResponse.json( + { algorithm: 'ed25519', publicKey }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } catch (err) { + logger.error('[plugin-signing-pubkey] load failed', { error: err instanceof Error ? err.message : String(err) }); + return NextResponse.json({ error: 'Signing key unavailable' }, { status: 500 }); + } +} diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 19b7a7cf..22c1154a 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -43,6 +43,8 @@ export async function GET() { dev: p.dev, // Surface so clients can enforce api.http.fetch origin allowlists. httpOrigins: p.httpOrigins, + // Surface so clients can enforce api.http.post path allowlists. + apiPostPaths: p.apiPostPaths, // Per-user settings schema, captured from the manifest at upload/load // time so the client can render the settings UI without re-parsing. settingsSchema: p.settingsSchema, diff --git a/lib/admin/csp-frame-origins.ts b/lib/admin/csp-frame-origins.ts index 4b6f69af..be1a8197 100644 --- a/lib/admin/csp-frame-origins.ts +++ b/lib/admin/csp-frame-origins.ts @@ -60,6 +60,36 @@ export function sanitizeFrameOrigins(input: unknown): string[] { export const sanitizeHttpOrigins = sanitizeFrameOrigins; export const isValidHttpOrigin = isValidFrameOrigin; +// ─── apiPostPaths (manifest field) ──────────────────────────── + +/** + * Validates an `/api/...` path entry. Must start with `/api/`, contain only + * URL-path-safe characters, and have no `..` segment. The trailing slash is + * meaningful (treated as a prefix at enforcement time). + */ +export function isValidApiPostPath(path: unknown): path is string { + if (typeof path !== 'string') return false; + if (path.length === 0 || path.length > 200) return false; + if (!path.startsWith('/api/')) return false; + if (path.includes('..')) return false; + if (/[\s'"`;,()?#]/.test(path)) return false; + if (!/^[/A-Za-z0-9._\-]+$/.test(path)) return false; + return true; +} + +export function sanitizeApiPostPaths(input: unknown): string[] { + if (!Array.isArray(input)) return []; + const seen = new Set(); + const out: string[] = []; + for (const value of input) { + if (!isValidApiPostPath(value)) continue; + if (seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} + // In-memory cache. The proxy fires on every page navigation; reading the // registry JSON every time is fine but cheap to skip when nothing has // changed. Five seconds is short enough to make plugin install/uninstall diff --git a/lib/admin/plugin-approvals.ts b/lib/admin/plugin-approvals.ts new file mode 100644 index 00000000..98e736b3 --- /dev/null +++ b/lib/admin/plugin-approvals.ts @@ -0,0 +1,175 @@ +// Server-side admin plugin-approval store. +// +// Closes the "C4" audit finding: previously a plugin's `adminApproved` flag +// was client-only, so a malicious user could enable a plugin past the policy +// gate via DevTools. The server now tracks per-(pluginId, bundleHash) status +// and the `enablePlugin` flow consults it before letting a non-managed plugin +// run. +// +// Each entry has one of three states: 'pending' (user installed, waiting for +// admin), 'approved' (admin signed off), 'denied' (admin refused — kept so we +// don't keep asking). + +import { readFile, writeFile, rename } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { logger } from '@/lib/logger'; +import { ensureConfigDir, getConfigPath, assertWritable } from './paths'; + +export type ApprovalStatus = 'pending' | 'approved' | 'denied'; + +export interface ApprovalEntry { + pluginId: string; + bundleHash: string; + status: ApprovalStatus; + /** Snapshot of the manifest at request time. */ + manifest: { + name?: string; + version?: string; + author?: string; + description?: string; + permissions?: string[]; + httpOrigins?: string[]; + apiPostPaths?: string[]; + }; + requestedBy: string; // JMAP username who triggered the request + requestedAt: string; // ISO 8601 + decidedBy?: string; // admin username (set on approve/deny) + decidedAt?: string; +} + +interface ApprovalsFile { + entries: ApprovalEntry[]; +} + +const APPROVALS_FILE = 'plugin-approvals.json'; +const MAX_ENTRIES = 500; // hard cap so a misbehaving client can't grow the file unboundedly + +let cached: ApprovalsFile | null = null; +let loadPromise: Promise | null = null; + +async function loadFromDisk(): Promise { + await ensureConfigDir(); + const path = getConfigPath(APPROVALS_FILE); + if (!existsSync(path)) return { entries: [] }; + try { + const raw = await readFile(path, 'utf-8'); + const parsed = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.entries)) return { entries: [] }; + return { entries: parsed.entries.filter(isWellFormed) }; + } catch (err) { + logger.warn('[plugin-approvals] failed to read file', { error: err instanceof Error ? err.message : String(err) }); + return { entries: [] }; + } +} + +async function ensureLoaded(): Promise { + if (cached !== null) return; + if (!loadPromise) { + loadPromise = (async () => { cached = await loadFromDisk(); })(); + } + await loadPromise; +} + +async function flushToDisk(): Promise { + if (!cached) return; + await ensureConfigDir(); + assertWritable('plugin-approvals.flushToDisk'); + const path = getConfigPath(APPROVALS_FILE); + const tmp = `${path}.tmp`; + await writeFile(tmp, JSON.stringify(cached, null, 2), 'utf-8'); + await rename(tmp, path); +} + +function isWellFormed(value: unknown): value is ApprovalEntry { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return ( + typeof v.pluginId === 'string' && + typeof v.bundleHash === 'string' && + (v.status === 'pending' || v.status === 'approved' || v.status === 'denied') && + typeof v.requestedBy === 'string' && + typeof v.requestedAt === 'string' && + typeof v.manifest === 'object' && v.manifest !== null + ); +} + +function findEntry(file: ApprovalsFile, pluginId: string, bundleHash: string): ApprovalEntry | undefined { + return file.entries.find(e => e.pluginId === pluginId && e.bundleHash === bundleHash); +} + +// ─── Public API ────────────────────────────────────────────── + +export async function listApprovals(): Promise { + await ensureLoaded(); + return [...cached!.entries]; +} + +export async function getApprovalStatus(pluginId: string, bundleHash: string): Promise<{ status: ApprovalStatus | 'not-requested'; decidedAt?: string }> { + await ensureLoaded(); + const entry = findEntry(cached!, pluginId, bundleHash); + if (!entry) return { status: 'not-requested' }; + return { status: entry.status, decidedAt: entry.decidedAt }; +} + +export async function requestApproval( + pluginId: string, + bundleHash: string, + manifest: ApprovalEntry['manifest'], + requestedBy: string, +): Promise { + if (!pluginId || !bundleHash) throw new Error('pluginId and bundleHash required'); + await ensureLoaded(); + const file = cached!; + const existing = findEntry(file, pluginId, bundleHash); + if (existing) return existing; + if (file.entries.length >= MAX_ENTRIES) { + // Drop the oldest pending entry so a new request can land. Approved/denied + // entries are preserved. + const oldestPendingIdx = file.entries.findIndex(e => e.status === 'pending'); + if (oldestPendingIdx >= 0) file.entries.splice(oldestPendingIdx, 1); + else throw new Error('plugin-approvals file is full'); + } + const entry: ApprovalEntry = { + pluginId, + bundleHash, + status: 'pending', + manifest, + requestedBy, + requestedAt: new Date().toISOString(), + }; + file.entries.push(entry); + await flushToDisk(); + return entry; +} + +export async function decideApproval( + pluginId: string, + bundleHash: string, + decision: 'approved' | 'denied', + decidedBy: string, +): Promise { + await ensureLoaded(); + const file = cached!; + const entry = findEntry(file, pluginId, bundleHash); + if (!entry) throw new Error('approval entry not found'); + entry.status = decision; + entry.decidedAt = new Date().toISOString(); + entry.decidedBy = decidedBy; + await flushToDisk(); + return entry; +} + +export async function revokeApproval(pluginId: string, bundleHash: string): Promise { + await ensureLoaded(); + const file = cached!; + const idx = file.entries.findIndex(e => e.pluginId === pluginId && e.bundleHash === bundleHash); + if (idx < 0) return; + file.entries.splice(idx, 1); + await flushToDisk(); +} + +/** Force a re-read on next access. Used in tests / after a manual file edit. */ +export function invalidateApprovalsCache(): void { + cached = null; + loadPromise = null; +} diff --git a/lib/admin/plugin-dev.ts b/lib/admin/plugin-dev.ts index 93563fb0..6a475c14 100644 --- a/lib/admin/plugin-dev.ts +++ b/lib/admin/plugin-dev.ts @@ -4,7 +4,7 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; import { logger } from '@/lib/logger'; import type { ServerPlugin } from './plugin-registry'; -import { sanitizeFrameOrigins, sanitizeHttpOrigins } from './csp-frame-origins'; +import { sanitizeFrameOrigins, sanitizeHttpOrigins, sanitizeApiPostPaths } from './csp-frame-origins'; /** * Dev-mode plugin loading. @@ -177,6 +177,7 @@ async function loadDevPlugin(pluginDir: string): Promise const frameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); const httpOrigins = sanitizeHttpOrigins(manifest.httpOrigins); + const apiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths); const plugin: ServerPlugin = { id, @@ -197,6 +198,7 @@ async function loadDevPlugin(pluginDir: string): Promise : {}), ...(frameOrigins.length > 0 ? { frameOrigins } : {}), ...(httpOrigins.length > 0 ? { httpOrigins } : {}), + ...(apiPostPaths.length > 0 ? { apiPostPaths } : {}), installedAt, updatedAt: new Date().toISOString(), bundleHash, diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index e09ca97a..be4b6b26 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -71,6 +71,11 @@ export interface ServerPlugin { * Same syntax as `frameOrigins`. Surfaced to clients via /api/plugins. */ httpOrigins?: string[]; + /** + * Same-origin `/api/*` path allowlist for `api.http.post()`. See + * `InstalledPlugin.apiPostPaths` in `lib/plugin-types.ts`. + */ + apiPostPaths?: string[]; } export interface ServerTheme { diff --git a/lib/admin/plugin-signing.ts b/lib/admin/plugin-signing.ts new file mode 100644 index 00000000..9f5f2ff7 --- /dev/null +++ b/lib/admin/plugin-signing.ts @@ -0,0 +1,98 @@ +// Server-side Ed25519 signing for plugin bundles. +// +// Closes the "C2" audit finding: SHA-256 alone catches transport corruption +// but not a compromised server-side bundle store. With signing, even if an +// attacker swaps the bundle bytes in transit or at rest, the client refuses +// to load anything that doesn't verify against the host's public key. +// +// The keypair lives at `data/admin/plugin-signing.key` (PEM-encoded +// PKCS#8 private, mode 0600) and is generated lazily on first use. Operators +// who want to pin the key out-of-band can drop a pre-generated PEM at that +// path before first boot — the loader just reads what's there. + +import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as nodeSign, KeyObject } from 'node:crypto'; +import { readFile, writeFile, chmod } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { ensureConfigDir, getConfigPath, assertWritable } from './paths'; +import { logger } from '@/lib/logger'; + +const KEY_FILENAME = 'plugin-signing.key'; + +let cached: { privateKey: KeyObject; publicKey: KeyObject } | null = null; +let initPromise: Promise | null = null; + +async function loadOrCreate(): Promise<{ privateKey: KeyObject; publicKey: KeyObject }> { + await ensureConfigDir(); + const path = getConfigPath(KEY_FILENAME); + + if (existsSync(path)) { + const pem = await readFile(path, 'utf-8'); + const privateKey = createPrivateKey({ key: pem, format: 'pem' }); + if (privateKey.asymmetricKeyType !== 'ed25519') { + throw new Error(`plugin-signing.key has wrong key type (${privateKey.asymmetricKeyType}); expected ed25519`); + } + const publicKey = createPublicKey(privateKey); + return { privateKey, publicKey }; + } + + // First boot: generate and persist. Use sync APIs so a half-written file + // never lingers if the process dies between writes. + assertWritable('plugin-signing.generateKeypair'); + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; + await writeFile(path, pem, { encoding: 'utf-8', mode: 0o600 }); + // Ensure 0600 on filesystems that ignored mode on writeFile. + try { await chmod(path, 0o600); } catch { /* best effort */ } + logger.info('[plugin-signing] generated new Ed25519 keypair'); + return { privateKey, publicKey }; +} + +async function ensureLoaded(): Promise { + if (cached) return; + if (!initPromise) { + initPromise = (async () => { + try { + cached = await loadOrCreate(); + } catch (err) { + initPromise = null; + logger.error('[plugin-signing] keypair load failed', { error: err instanceof Error ? err.message : String(err) }); + throw err; + } + })(); + } + await initPromise; +} + +// ─── Public API ────────────────────────────────────────────── + +/** Returns the public key as a raw 32-byte Uint8Array (Ed25519 standard form). */ +export async function getPublicKeyRaw(): Promise { + await ensureLoaded(); + // Export as SPKI DER and pull the last 32 bytes (the raw key after the + // 12-byte AlgorithmIdentifier prefix). Node has no built-in raw export + // for Ed25519, but the SPKI prefix is fixed for Ed25519 so the slice is + // safe. + const spki = cached!.publicKey.export({ type: 'spki', format: 'der' }) as Buffer; + if (spki.length < 32) throw new Error('SPKI export too short'); + return new Uint8Array(spki.subarray(spki.length - 32)); +} + +/** Base64-encoded raw 32-byte public key (for embedding in HTTP responses). */ +export async function getPublicKeyBase64(): Promise { + const raw = await getPublicKeyRaw(); + return Buffer.from(raw).toString('base64'); +} + +/** Sign `bytes` and return a base64-encoded 64-byte Ed25519 signature. */ +export async function signBytes(bytes: Uint8Array | string): Promise { + await ensureLoaded(); + const data = typeof bytes === 'string' ? Buffer.from(bytes, 'utf-8') : Buffer.from(bytes); + const sig = nodeSign(null, data, cached!.privateKey); + return sig.toString('base64'); +} + +/** Force a re-read on next access. Used after operator rotates the key. */ +export function invalidatePluginSigningCache(): void { + cached = null; + initPromise = null; +} diff --git a/lib/plugin-sandbox/bundle-signing.ts b/lib/plugin-sandbox/bundle-signing.ts new file mode 100644 index 00000000..1298789c --- /dev/null +++ b/lib/plugin-sandbox/bundle-signing.ts @@ -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 | null = null; + +async function importEd25519PublicKey(raw: Uint8Array): Promise { + 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 { + 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 { + 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 { + 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; +} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 9084f769..b1eb4fc4 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -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 = { '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); diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 1a467dd2..365ce1de 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -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': diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 7eeee9cc..18c41bbd 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -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` 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 unknown)); } + // Install plugin-declared keyboard shortcuts. + const shortcutDispose = registerShortcuts(background, info.shortcuts ?? []); + hookDisposables.push({ dispose: shortcutDispose }); + registerActive({ plugin, code, diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index fd536e9e..394ef21d 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -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:` 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; } diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index d51fabf0..e61260e5 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -34,6 +34,16 @@ import type { SlotName } from '../plugin-types'; interface PluginExports { slots?: Record>; shouldShow?: (ctx: unknown) => boolean; order?: number }>; hooks?: Record unknown>; + /** + * Keyboard shortcut bindings. Each entry's `handler` is registered as a + * hook named `shortcut:` so the host's keydown dispatcher can fire it. + */ + shortcuts?: Record void | Promise; + }>; activate?: (api: unknown) => void | Promise | { dispose: () => void }; default?: unknown; } @@ -243,12 +253,28 @@ async function bootBackground(payload: BackgroundInit): Promise { } } + // Shortcuts: register each handler as a 'shortcut:' 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. diff --git a/lib/plugin-sandbox/shortcuts.ts b/lib/plugin-sandbox/shortcuts.ts new file mode 100644 index 00000000..73a94b3f --- /dev/null +++ b/lib/plugin-sandbox/shortcuts.ts @@ -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:', [])`. +// +// 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; +} + +interface NormalisedCombo { + ctrl: boolean; + shift: boolean; + alt: boolean; + meta: boolean; + key: string; +} + +const bindings = new Map(); // 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, + })); +} diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 9c39a27b..ec664381 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -127,6 +127,15 @@ export interface PluginManifest { * The remote host must serve CORS headers permitting the webmail origin. */ httpOrigins?: string[]; + /** + * Same-origin `/api/*` paths this plugin may target via `api.http.post()`. + * Each entry is a path prefix; a call to `api.http.post('/api/X', ...)` is + * accepted iff `'/api/X'` exactly equals an entry OR an entry ends in + * `/` and `'/api/X'` starts with it. With no entry (or an empty array), + * the plugin may not call `api.http.post` even with the `http:post` + * permission. Validated at install time. + */ + apiPostPaths?: string[]; // ─── Marketplace media (NOT shipped in the runtime zip) ────── /** @@ -224,6 +233,11 @@ export interface InstalledPlugin { * `api.http.fetch()`. Carried over from the manifest at install time. */ httpOrigins?: string[]; + /** + * Validated allowlist of same-origin `/api/*` paths this plugin may target + * via `api.http.post()`. Carried over from the manifest at install time. + */ + apiPostPaths?: string[]; /** * Permissions the user has explicitly granted. Populated by the in-app * consent dialog the first time the plugin is enabled. The host API gate diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 52c92e5a..b8f56d4d 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -9,6 +9,8 @@ import { extractPlugin } from '@/lib/plugin-validator'; import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable } from '@/lib/plugin-loader'; import { removeAllPluginHooks } from '@/lib/plugin-hooks'; import { requestConsent } from '@/lib/plugin-sandbox/consent'; +import { sha256Hex } from '@/lib/plugin-sandbox/bundle-integrity'; +import { verifySignature } from '@/lib/plugin-sandbox/bundle-signing'; import { usePolicyStore } from '@/stores/policy-store'; import { apiFetch } from '@/lib/browser-navigation'; import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types'; @@ -60,6 +62,10 @@ export const usePluginStore = create()( deactivatePlugin(manifest.id); } + // Compute bundleHash so the admin-approval gate can pin to this + // specific bundle (server-side state keys on (id, hash) pairs). + const bundleHash = await sha256Hex(code).catch(() => undefined); + const plugin: InstalledPlugin = { id: manifest.id, name: manifest.name, @@ -76,9 +82,13 @@ export const usePluginStore = create()( adminApproved: false, // Requires admin approval before it can be enabled settings: existing?.settings ?? {}, settingsSchema: manifest.settingsSchema, + ...(bundleHash ? { bundleHash } : {}), ...(manifest.httpOrigins && manifest.httpOrigins.length > 0 ? { httpOrigins: manifest.httpOrigins } : {}), + ...(manifest.apiPostPaths && manifest.apiPostPaths.length > 0 + ? { apiPostPaths: manifest.apiPostPaths } + : {}), }; // Save code to IndexedDB @@ -127,10 +137,37 @@ export const usePluginStore = create()( const plugin = plugins.find(p => p.id === id); if (!plugin) return; - // Block enabling if plugin requires admin approval and hasn't been approved + // Admin approval gate. Managed (admin-pushed) plugins are pre- + // approved. For user-installed plugins the server-side state is + // authoritative: the client-only `isPluginApproved` flag is kept as + // a fast-path hint but the server result wins. const requireApproval = usePolicyStore.getState().isFeatureEnabled('requirePluginApproval'); - const isApproved = plugin.adminApproved || plugin.managed || usePolicyStore.getState().isPluginApproved(id); - if (requireApproval && !isApproved) return; + const policyApproved = plugin.adminApproved || plugin.managed || usePolicyStore.getState().isPluginApproved(id); + if (requireApproval && !policyApproved && plugin.bundleHash) { + const status = await checkServerApproval(plugin.id, plugin.bundleHash).catch(() => null); + if (status?.status === 'approved') { + // Approval available; proceed. + } else if (status?.status === 'denied') { + set(state => ({ + plugins: state.plugins.map(p => + p.id === id ? { ...p, status: 'error' as PluginStatus, error: 'Plugin denied by administrator' } : p + ), + })); + return; + } else { + // 'pending' or 'not-requested' — submit a request and refuse to enable. + await submitApprovalRequest(plugin).catch(() => { /* best effort */ }); + set(state => ({ + plugins: state.plugins.map(p => + p.id === id ? { ...p, status: 'error' as PluginStatus, error: 'Awaiting administrator approval' } : p + ), + })); + return; + } + } else if (requireApproval && !policyApproved) { + // No bundleHash means we can't pin the approval — refuse. + return; + } // Per-user consent gate: prompt for any permission the user has not // explicitly approved yet. Managed plugins (admin-pushed) skip this — @@ -283,6 +320,8 @@ interface ServerPluginInfo { dev?: boolean; /** Allowlist of origins this plugin may target via api.http.fetch(). */ httpOrigins?: string[]; + /** Allowlist of same-origin /api/* paths this plugin may target via api.http.post(). */ + apiPostPaths?: string[]; /** Per-user settings schema, captured from the manifest server-side. */ settingsSchema?: InstalledPlugin['settingsSchema']; } @@ -389,6 +428,9 @@ async function syncServerPlugins( ...(sp.httpOrigins && sp.httpOrigins.length > 0 ? { httpOrigins: sp.httpOrigins } : {}), + ...(sp.apiPostPaths && sp.apiPostPaths.length > 0 + ? { apiPostPaths: sp.apiPostPaths } + : {}), }; set(state => { @@ -425,6 +467,7 @@ async function syncServerPlugins( forceEnabled: sp.forceEnabled, bundleHash: sp.bundleHash, httpOrigins: sp.httpOrigins, + apiPostPaths: sp.apiPostPaths, settingsSchema: sp.settingsSchema, } : p @@ -498,9 +541,63 @@ async function downloadPluginBundle(pluginId: string, bundleHash?: string): Prom const suffix = bundleHash ? `?v=${encodeURIComponent(bundleHash)}` : ''; const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/bundle${suffix}`); if (!res.ok) return null; - return await res.text(); + const code = await res.text(); + // Ed25519 signature verification. Present on every server-managed bundle + // since the signing module is server-side; refuse to persist a bundle + // that fails verification. If the header is missing (older server / dev + // build with signing disabled) we log and allow — the SHA-256 hash check + // at load time still catches transport corruption. + const sig = res.headers.get('X-Bundle-Signature'); + if (sig) { + const ok = await verifySignature(code, sig); + if (!ok) { + console.error(`[plugin-store] Refusing bundle for "${pluginId}": signature verification failed`); + return null; + } + } else { + console.warn(`[plugin-store] Bundle for "${pluginId}" has no Ed25519 signature; loading without it`); + } + return code; } catch { console.warn(`[plugin-store] Failed to download bundle for plugin "${pluginId}"`); return null; } } + +// ─── Server-side admin-approval helpers ─────────────────────── + +async function checkServerApproval(pluginId: string, bundleHash: string): Promise<{ status: 'pending' | 'approved' | 'denied' | 'not-requested' } | null> { + try { + const url = `/api/plugin-approval-status?pluginId=${encodeURIComponent(pluginId)}&bundleHash=${encodeURIComponent(bundleHash)}`; + const res = await apiFetch(url); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +async function submitApprovalRequest(plugin: InstalledPlugin): Promise { + if (!plugin.bundleHash) return; + try { + await apiFetch('/api/plugin-approval-status', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pluginId: plugin.id, + bundleHash: plugin.bundleHash, + manifest: { + name: plugin.name, + version: plugin.version, + author: plugin.author, + description: plugin.description, + permissions: plugin.permissions, + httpOrigins: plugin.httpOrigins, + apiPostPaths: plugin.apiPostPaths, + }, + }), + }); + } catch { + /* best effort */ + } +}