176 lines
5.9 KiB
TypeScript
176 lines
5.9 KiB
TypeScript
// 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<void> | null = null;
|
|
|
|
async function loadFromDisk(): Promise<ApprovalsFile> {
|
|
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<void> {
|
|
if (cached !== null) return;
|
|
if (!loadPromise) {
|
|
loadPromise = (async () => { cached = await loadFromDisk(); })();
|
|
}
|
|
await loadPromise;
|
|
}
|
|
|
|
async function flushToDisk(): Promise<void> {
|
|
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<string, unknown>;
|
|
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<ApprovalEntry[]> {
|
|
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<ApprovalEntry> {
|
|
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<ApprovalEntry> {
|
|
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<void> {
|
|
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;
|
|
}
|