// 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). // // Stored in C/plugin-approvals.json, or the shared store in shared mode // (#1066). Every change goes through update() against the stored document, // so requests and decisions arriving at once (on one instance or several) // all stay, then announces area `approvals`: every replica re-reads its copy, // which also feeds the plugin-secrets credential gate (known-plugins.ts). import { logger } from '@/lib/logger'; import { isSharedMode } from '@/lib/shared-store'; import { announceChange, onSharedChange } from '@/lib/shared-store/changes'; import { ensureConfigDir, assertWritable } from './paths'; import { isStoreUnavailable } from './admin-store'; import { pluginApprovalDocs } from './extension-store'; import type { CredentialSpec } from '@/lib/plugin-secrets/credentials'; 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[]; /** The credentials it may use through the plugin proxy (lib/plugin-secrets). */ credentials?: Record; }; requestedBy: string; // JMAP username who triggered the request requestedAt: string; // ISO 8601 decidedBy?: string; // admin username (set on approve/deny) decidedAt?: string; /** Size of the package kept for review (plugin-approvals/-.zip), when one is. */ packageBytes?: number; } 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 /** Open requests one user may have at a time, so one account can't crowd out the rest. */ export const MAX_PENDING_PER_USER = 10; /** The package a user asked to have approved, kept so the admin can read the code. */ function bundleId(pluginId: string, bundleHash: string): string { return `plugin-approvals/${pluginId}-${bundleHash}.zip`; } /** * The packages kept for review hold at most this much together. Beyond it * the packages of the longest-decided requests go first; pending ones are * always kept (the admin still has to read them). Packages stored before * their size was recorded aren't counted. */ let maxPackageBytes = 100 * 1024 * 1024; /** A smaller cap (tests). */ export function setApprovalPackageCapForTests(bytes: number | null): void { maxPackageBytes = bytes ?? 100 * 1024 * 1024; } export async function saveApprovalBundle(pluginId: string, bundleHash: string, zip: Uint8Array): Promise { assertWritable('plugin-approvals.saveBundle'); await pluginApprovalDocs().put(bundleId(pluginId, bundleHash), Buffer.from(zip)); let dropped: Array<{ pluginId: string; bundleHash: string }> = []; await changeStored((file) => { dropped = []; let changed = false; const entry = findEntry(file, pluginId, bundleHash); if (entry && entry.packageBytes !== zip.byteLength) { entry.packageBytes = zip.byteLength; changed = true; } let total = file.entries.reduce((sum, e) => sum + (e.packageBytes ?? 0), 0); const oldestDecidedFirst = file.entries .filter((e) => e.status !== 'pending' && e.packageBytes) .sort((a, b) => (a.decidedAt ?? a.requestedAt).localeCompare(b.decidedAt ?? b.requestedAt)); for (const old of oldestDecidedFirst) { if (total <= maxPackageBytes) break; total -= old.packageBytes!; delete old.packageBytes; dropped.push({ pluginId: old.pluginId, bundleHash: old.bundleHash }); changed = true; } return changed; }); for (const old of dropped) await deleteApprovalBundle(old.pluginId, old.bundleHash); } export async function readApprovalBundle(pluginId: string, bundleHash: string): Promise { try { const doc = await pluginApprovalDocs().get(bundleId(pluginId, bundleHash)); return doc ? new Uint8Array(doc.data) : null; } catch (error) { if (isStoreUnavailable(error)) throw error; return null; } } async function deleteApprovalBundle(pluginId: string, bundleHash: string): Promise { try { await pluginApprovalDocs().delete(bundleId(pluginId, bundleHash)); } catch { /* none */ } } // The copy every module copy (admin routes, the approval-status route, the // plugin-secrets gate) reads, on globalThis. In shared mode another // replica's change marks it stale; while the store is down the stale copy // keeps being served. interface ApprovalsGlobals { cached: ApprovalsFile | null; stale: boolean; loading: Promise | null; } const GLOBAL_KEY = Symbol.for('bulwark.admin.pluginApprovals'); function approvalsGlobals(): ApprovalsGlobals { const g = globalThis as typeof globalThis & { [GLOBAL_KEY]?: ApprovalsGlobals }; return (g[GLOBAL_KEY] ??= { cached: null, stale: false, loading: null }); } onSharedChange('approvals', (origin) => { // This process's own writes already replaced the copy. if (origin === 'remote') approvalsGlobals().stale = true; }); function parseFile(data: Buffer | null): ApprovalsFile { if (!data) return { entries: [] }; try { const parsed = JSON.parse(data.toString('utf-8')); 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 loadStored(): Promise { if (!isSharedMode()) await ensureConfigDir(); let data: Buffer | null; try { data = (await pluginApprovalDocs().get(APPROVALS_FILE))?.data ?? null; } catch (err) { if (isStoreUnavailable(err)) throw err; logger.warn('[plugin-approvals] failed to read file', { error: err instanceof Error ? err.message : String(err) }); return { entries: [] }; } return parseFile(data); } async function ensureLoaded(): Promise { const g = approvalsGlobals(); if (g.cached && !g.stale) return g.cached; if (!g.loading) { g.stale = false; g.loading = (async () => { try { g.cached = await loadStored(); } catch (err) { // Store down: keep serving the copy we have, read again next time. if (!(isStoreUnavailable(err) && g.cached)) throw err; g.stale = true; } finally { g.loading = null; } })(); } await g.loading; return g.cached!; } /** * Change the stored file with `change`, which edits the parsed file and * returns true when it changed something. It may run more than once. */ async function changeStored(change: (file: ApprovalsFile) => boolean): Promise { let changed = false; const result = await pluginApprovalDocs().update(APPROVALS_FILE, (current) => { const file = parseFile(current); changed = change(file); return changed ? JSON.stringify(file, null, 2) : undefined; }); if (changed && result.data) { approvalsGlobals().cached = parseFile(result.data); approvalsGlobals().stale = false; await announceChange('approvals'); } return changed; } 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 { return [...(await ensureLoaded()).entries]; } export async function getApprovalStatus(pluginId: string, bundleHash: string): Promise<{ status: ApprovalStatus | 'not-requested'; decidedAt?: string }> { const entry = findEntry(await ensureLoaded(), 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'); const existing = findEntry(await ensureLoaded(), pluginId, bundleHash); if (existing) return existing; assertWritable('request plugin approval'); const requestedAt = new Date().toISOString(); let result: ApprovalEntry | null = null; let dropped: ApprovalEntry | null = null; await changeStored((file) => { dropped = null; const stored = findEntry(file, pluginId, bundleHash); if (stored) { result = stored; return false; } const pendingByUser = file.entries.filter(e => e.status === 'pending' && e.requestedBy === requestedBy).length; if (pendingByUser >= MAX_PENDING_PER_USER) { throw new ApprovalLimitError(`You already have ${MAX_PENDING_PER_USER} plugins waiting for approval`); } 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) { throw new ApprovalLimitError('The approval queue is full; ask an administrator to clear old requests'); } [dropped] = file.entries.splice(oldestPendingIdx, 1); } const entry: ApprovalEntry = { pluginId, bundleHash, status: 'pending', manifest, requestedBy, requestedAt, }; file.entries.push(entry); result = entry; return true; }); const gone = dropped as ApprovalEntry | null; if (gone) await deleteApprovalBundle(gone.pluginId, gone.bundleHash); return result!; } export async function decideApproval( pluginId: string, bundleHash: string, decision: 'approved' | 'denied', decidedBy: string, ): Promise { assertWritable('decide plugin approval'); const decidedAt = new Date().toISOString(); let decided: ApprovalEntry | null = null; await changeStored((file) => { const entry = findEntry(file, pluginId, bundleHash); if (!entry) throw new Error('approval entry not found'); entry.status = decision; entry.decidedAt = decidedAt; entry.decidedBy = decidedBy; decided = entry; return true; }); return decided!; } export async function revokeApproval(pluginId: string, bundleHash: string): Promise { assertWritable('revoke plugin approval'); const removed = await changeStored((file) => { const idx = file.entries.findIndex(e => e.pluginId === pluginId && e.bundleHash === bundleHash); if (idx < 0) return false; file.entries.splice(idx, 1); return true; }); if (removed) await deleteApprovalBundle(pluginId, bundleHash); } export class ApprovalLimitError extends Error {} /** Forget the copy in memory (tests: a fresh process). */ export function resetApprovalsCacheForTests(): void { const g = approvalsGlobals(); g.cached = null; g.stale = false; g.loading = null; }