feat: lock down plugin runtime in sandbox + signing + approval
This commit is contained in:
@@ -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<string>();
|
||||
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
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
@@ -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<DevPluginEntry | null>
|
||||
|
||||
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<DevPluginEntry | null>
|
||||
: {}),
|
||||
...(frameOrigins.length > 0 ? { frameOrigins } : {}),
|
||||
...(httpOrigins.length > 0 ? { httpOrigins } : {}),
|
||||
...(apiPostPaths.length > 0 ? { apiPostPaths } : {}),
|
||||
installedAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
bundleHash,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<void> | 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<void> {
|
||||
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<Uint8Array> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user