mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(plugins): sandbox hardening — OS permission model, egress choke point, bound acting user, author signatures
Closes the four gaps the security review surfaced: - OS permission model on the prod plugin child (Node --permission with fs-read scoped to the compiled server dir + the plugin's own code dir, no fs-write/child_process/worker/native). A plugin can no longer read trek.db or the .jwt_secret/.encryption_key files, nor shell out — the direct-fs and RCE escapes that bypassed the RPC layer. Opt-out via TREK_PLUGIN_PERMISSIONS=off. - Egress guard extended from fetch to the net.Socket connect choke point, so node:http/https/net/tls obey the declared-egress allowlist too (no declared egress = no outbound). Under the permission model there is no clean escape to an unwrapped runtime. Kernel/network-namespace containment remains the container step. - Trip reads are membership-checked against the acting user the HOST binds from the authenticated invocation, not an asUserId the plugin supplies; a job/onLoad (no user) can't read user-scoped trips. - Optional minisign (Ed25519) author signatures verified offline, TOFU-pinned (migration 158). Unsigned plugins install on sha256 alone; a signed plugin can't silently drop its signature or swap its author key. Server suite green (permission-model activation verified against the Koffi reference plugin on dev1).
This commit is contained in:
@@ -3370,6 +3370,13 @@ function runMigrations(db: Database.Database): void {
|
||||
() => {
|
||||
try { db.exec("ALTER TABLE plugins ADD COLUMN capabilities TEXT NOT NULL DEFAULT '{}';"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
|
||||
},
|
||||
// Migration 158: TOFU pin for a plugin's author signing key (#plugins, #4).
|
||||
// Set on first install of a signed plugin; a later install whose registry key
|
||||
// differs is a hard stop (author change / key rotation / attack) unless an
|
||||
// admin re-trusts. NULL for unsigned plugins (signing is opt-in).
|
||||
() => {
|
||||
try { db.exec("ALTER TABLE plugins ADD COLUMN author_pubkey TEXT;"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface HostDeps {
|
||||
broadcastToUser(userId: number, payload: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
type Handler = (params: Record<string, unknown>) => unknown;
|
||||
type Handler = (params: Record<string, unknown>, actingUserId: number | undefined) => unknown;
|
||||
|
||||
const num = (v: unknown, name: string): number => {
|
||||
const n = typeof v === 'string' ? Number(v) : v;
|
||||
@@ -71,14 +71,14 @@ export class PluginRpcHost {
|
||||
}
|
||||
|
||||
if (has('db:read:trips')) {
|
||||
this.methods.set('trips.getById', (p) =>
|
||||
this.tripRead(p, () => deps.db.prepare('SELECT * FROM trips WHERE id = ?').get(num(p.tripId, 'tripId'))),
|
||||
this.methods.set('trips.getById', (p, uid) =>
|
||||
this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM trips WHERE id = ?').get(num(p.tripId, 'tripId'))),
|
||||
);
|
||||
this.methods.set('trips.getPlaces', (p) =>
|
||||
this.tripRead(p, () => deps.db.prepare('SELECT * FROM places WHERE trip_id = ? ORDER BY day_id, position').all(num(p.tripId, 'tripId'))),
|
||||
this.methods.set('trips.getPlaces', (p, uid) =>
|
||||
this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM places WHERE trip_id = ? ORDER BY day_id, position').all(num(p.tripId, 'tripId'))),
|
||||
);
|
||||
this.methods.set('trips.getReservations', (p) =>
|
||||
this.tripRead(p, () => deps.db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time').all(num(p.tripId, 'tripId'))),
|
||||
this.methods.set('trips.getReservations', (p, uid) =>
|
||||
this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time').all(num(p.tripId, 'tripId'))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,17 +104,25 @@ export class PluginRpcHost {
|
||||
}
|
||||
}
|
||||
|
||||
/** Membership-check every trip read against the acting user; forbid otherwise. */
|
||||
private tripRead(p: Record<string, unknown>, read: () => unknown): unknown {
|
||||
/**
|
||||
* Membership-check every trip read against the acting user. The acting user is
|
||||
* bound by the HOST from the authenticated invocation (see the supervisor's
|
||||
* invocation map) — NOT taken from a plugin-supplied `asUserId`, which a plugin
|
||||
* could set to any id to read another user's trips. If no acting user is bound
|
||||
* (a job / onLoad, or a forged call), the read is forbidden.
|
||||
*/
|
||||
private tripRead(p: Record<string, unknown>, actingUserId: number | undefined, read: () => unknown): unknown {
|
||||
const tripId = num(p.tripId, 'tripId');
|
||||
const asUserId = num(p.asUserId, 'asUserId');
|
||||
if (!this.deps.canAccessTrip(tripId, asUserId)) {
|
||||
if (actingUserId === undefined) {
|
||||
throw new ForbiddenResource('trip reads require an authenticated user context');
|
||||
}
|
||||
if (!this.deps.canAccessTrip(tripId, actingUserId)) {
|
||||
throw new ForbiddenResource(`no access to trip ${tripId}`);
|
||||
}
|
||||
return read();
|
||||
}
|
||||
|
||||
async dispatch(req: RpcRequest): Promise<RpcResponse | RpcError> {
|
||||
async dispatch(req: RpcRequest, actingUserId?: number): Promise<RpcResponse | RpcError> {
|
||||
const handler = this.methods.get(req.method);
|
||||
if (!handler) {
|
||||
const known = (KNOWN_METHODS as readonly string[]).includes(req.method as KnownMethod);
|
||||
@@ -128,7 +136,7 @@ export class PluginRpcHost {
|
||||
}
|
||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||
try {
|
||||
const result = await handler(params);
|
||||
const result = await handler(params, actingUserId);
|
||||
return { k: 'res', id: req.id, ok: true, result };
|
||||
} catch (e) {
|
||||
if (e instanceof BadParams) return this.err(req.id, 'BAD_PARAMS', e.message);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Author-signature verification for the plugin installer (#plugins, #4).
|
||||
*
|
||||
* sha256 pinning proves the bytes match what the REGISTRY vouches for; a whoever
|
||||
* controls the registry repo controls that. A minisign (Ed25519) author
|
||||
* signature proves the bytes were signed by the holder of the plugin AUTHOR's
|
||||
* private key — so a compromised registry cannot ship attacker code under an
|
||||
* author's name without also stealing that key. TREK verifies offline with
|
||||
* node:crypto (no Fulcio/Rekor phone-home), consistent with the "no telemetry
|
||||
* beyond the update check" stance.
|
||||
*
|
||||
* Signing is OPT-IN: an entry without an author key + signature installs exactly
|
||||
* as before (sha256 only). When both are present, verification is mandatory and
|
||||
* a mismatch aborts the install.
|
||||
*/
|
||||
|
||||
// SPKI DER prefix for a raw 32-byte Ed25519 public key (RFC 8410).
|
||||
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
||||
|
||||
export class SignatureError extends Error {}
|
||||
|
||||
/** Wrap a raw 32-byte Ed25519 public key as a node KeyObject. */
|
||||
function ed25519KeyFromRaw(raw: Buffer): crypto.KeyObject {
|
||||
if (raw.length !== 32) throw new SignatureError('public key is not a 32-byte Ed25519 key');
|
||||
return crypto.createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a minisign public key. Accepts either the two-line minisign format
|
||||
* (comment line + base64 payload) or a bare base64 payload. The 10-byte payload
|
||||
* header is `Ed` + an 8-byte key id; the trailing 32 bytes are the Ed25519 key.
|
||||
* A bare 32-byte base64 key (44 chars) is also accepted for simple deployments.
|
||||
*/
|
||||
function parseMinisignPubKey(pub: string): { key: crypto.KeyObject; keyId: Buffer | null } {
|
||||
const line = pub.trim().split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('untrusted comment')).pop();
|
||||
if (!line) throw new SignatureError('empty public key');
|
||||
const buf = Buffer.from(line, 'base64');
|
||||
if (buf.length === 32) return { key: ed25519KeyFromRaw(buf), keyId: null };
|
||||
if (buf.length === 42 && buf.subarray(0, 2).toString('latin1') === 'Ed') {
|
||||
return { key: ed25519KeyFromRaw(buf.subarray(10)), keyId: buf.subarray(2, 10) };
|
||||
}
|
||||
throw new SignatureError('unrecognized public key format');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a minisign signature (.minisig). The relevant line is the first base64
|
||||
* payload: 2-byte algorithm (`Ed` legacy | `ED` prehashed) + 8-byte key id +
|
||||
* 64-byte Ed25519 signature. A bare 64-byte base64 signature (legacy over the
|
||||
* raw bytes) is also accepted.
|
||||
*/
|
||||
function parseMinisignSignature(sig: string): { algo: 'Ed' | 'ED' | 'raw'; keyId: Buffer | null; signature: Buffer } {
|
||||
const line = sig.trim().split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('untrusted comment')).shift();
|
||||
if (!line) throw new SignatureError('empty signature');
|
||||
const buf = Buffer.from(line, 'base64');
|
||||
if (buf.length === 64) return { algo: 'raw', keyId: null, signature: buf };
|
||||
if (buf.length === 74) {
|
||||
const algo = buf.subarray(0, 2).toString('latin1');
|
||||
if (algo !== 'Ed' && algo !== 'ED') throw new SignatureError(`unsupported signature algorithm ${algo}`);
|
||||
return { algo, keyId: buf.subarray(2, 10), signature: buf.subarray(10) };
|
||||
}
|
||||
throw new SignatureError('unrecognized signature format');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that `bytes` were signed by the holder of `publicKeyB64`'s private key.
|
||||
* Supports minisign legacy (`Ed`, signs the raw file) and prehashed (`ED`, signs
|
||||
* BLAKE2b-512 of the file), plus bare raw Ed25519 over the file bytes. Returns
|
||||
* true on a valid signature, throws SignatureError on a malformed input, and
|
||||
* returns false on a well-formed but non-matching signature.
|
||||
*/
|
||||
export function verifyAuthorSignature(bytes: Buffer, signatureB64: string, publicKeyB64: string): boolean {
|
||||
const { key, keyId } = parseMinisignPubKey(publicKeyB64);
|
||||
const { algo, keyId: sigKeyId, signature } = parseMinisignSignature(signatureB64);
|
||||
|
||||
// If both carry a key id, they must match the same key (catches a wrong-key mixup).
|
||||
if (keyId && sigKeyId && !keyId.equals(sigKeyId)) return false;
|
||||
|
||||
const message =
|
||||
algo === 'ED' ? crypto.createHash('blake2b512').update(bytes).digest() : bytes;
|
||||
try {
|
||||
return crypto.verify(null, message, key, signature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export function pluginDbFile(id: string): string {
|
||||
* so no `.js` sibling exists — fall back to the `.ts` source loaded via tsx (a
|
||||
* prod dependency). This keeps the fork path identical in tests and production.
|
||||
*/
|
||||
export function resolveChildEntry(): { entry: string; execArgv: string[]; forkCwd?: string } {
|
||||
export function resolveChildEntry(): { entry: string; execArgv: string[]; forkCwd?: string; jsMode: boolean } {
|
||||
const js = path.join(__dirname, 'runtime', 'plugin-host-entry.js');
|
||||
const ts = path.join(__dirname, 'runtime', 'plugin-host-entry.ts');
|
||||
if (!fs.existsSync(js) && fs.existsSync(ts)) {
|
||||
@@ -53,8 +53,43 @@ export function resolveChildEntry(): { entry: string; execArgv: string[]; forkCw
|
||||
// must run from a dir where `tsx` is on the node_modules chain (the server
|
||||
// root) — NOT the plugin dir. The plugin itself is still loaded by absolute
|
||||
// path via createRequire, so this doesn't weaken the prod isolation, where
|
||||
// the .js branch below keeps cwd at the plugin dir.
|
||||
return { entry: ts, execArgv: ['--import', 'tsx'], forkCwd: process.cwd() };
|
||||
// the .js branch below keeps cwd at the plugin dir. tsx compiles TS on the
|
||||
// fly and walks node_modules, so the OS-level permission model can't be
|
||||
// applied here — it is a prod-only hardening (jsMode).
|
||||
return { entry: ts, execArgv: ['--import', 'tsx'], forkCwd: process.cwd(), jsMode: false };
|
||||
}
|
||||
return { entry: js, execArgv: ['--max-old-space-size=192'] };
|
||||
return { entry: js, execArgv: ['--max-old-space-size=192'], jsMode: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The compiled server code root (…/dist), computed from this module's location.
|
||||
* The plugin child needs read access to it to load its bootstrap + SDK; it must
|
||||
* NOT be granted the data root (trek.db, .jwt_secret, .encryption_key live there).
|
||||
*/
|
||||
export function serverCodeRoot(): string {
|
||||
// __dirname = …/dist/nest/plugins → …/dist
|
||||
return path.resolve(__dirname, '../..');
|
||||
}
|
||||
|
||||
/**
|
||||
* Node OS-level permission-model flags for a plugin child (#plugins, #2 security
|
||||
* hardening). `--permission` denies filesystem write, child_process, worker
|
||||
* threads and native addons outright; `--allow-fs-read` is scoped to exactly the
|
||||
* code the child must load (the compiled server dir + this plugin's own code
|
||||
* dir). Result: the child can no longer read trek.db / the secret files or shell
|
||||
* out, closing the direct-filesystem and RCE escapes that bypass the RPC layer.
|
||||
* Empty (opt-out) when TREK_PLUGIN_PERMISSIONS=off.
|
||||
*/
|
||||
export function pluginPermissionArgs(pluginId: string): string[] {
|
||||
if ((process.env.TREK_PLUGIN_PERMISSIONS ?? 'on').toLowerCase() === 'off') return [];
|
||||
const codeRoot = serverCodeRoot();
|
||||
return [
|
||||
'--permission',
|
||||
`--allow-fs-read=${codeRoot}`,
|
||||
// The server package.json sits one level above dist and Node reads it to
|
||||
// resolve the child's module type. Grant just that file — never its dir,
|
||||
// which also holds the `data` symlink to trek.db and the secret files.
|
||||
`--allow-fs-read=${path.join(codeRoot, '..', 'package.json')}`,
|
||||
`--allow-fs-read=${pluginCodeDir(pluginId)}`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -128,8 +128,8 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
routesOf(id: string): PluginRouteInfo[] {
|
||||
return this.supervisor.routesOf(id);
|
||||
}
|
||||
invoke(id: string, method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
return this.supervisor.invoke(id, method, params);
|
||||
invoke(id: string, method: string, params: Record<string, unknown>, actingUserId?: number): Promise<unknown> {
|
||||
return this.supervisor.invoke(id, method, params, { actingUserId });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,16 +48,23 @@ export class PluginsProxyController {
|
||||
}
|
||||
|
||||
try {
|
||||
const reply = (await this.runtime.invoke(pluginId, 'invoke.route', {
|
||||
routeId: route.i,
|
||||
req: {
|
||||
method: req.method,
|
||||
path: sub,
|
||||
query: req.query,
|
||||
body: req.body ?? null,
|
||||
user: user ? { id: user.id, username: user.username, isAdmin: !!user.is_admin } : null,
|
||||
const reply = (await this.runtime.invoke(
|
||||
pluginId,
|
||||
'invoke.route',
|
||||
{
|
||||
routeId: route.i,
|
||||
req: {
|
||||
method: req.method,
|
||||
path: sub,
|
||||
query: req.query,
|
||||
body: req.body ?? null,
|
||||
user: user ? { id: user.id, username: user.username, isAdmin: !!user.is_admin } : null,
|
||||
},
|
||||
},
|
||||
})) as { status?: number; headers?: Record<string, string>; body?: unknown };
|
||||
// Bind the authenticated session user as the acting user for any trip
|
||||
// reads this invocation makes — the plugin cannot override it.
|
||||
user?.id,
|
||||
)) as { status?: number; headers?: Record<string, string>; body?: unknown };
|
||||
|
||||
res.status(reply?.status ?? 200);
|
||||
for (const [k, v] of Object.entries(reply?.headers ?? {})) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
||||
import { db } from '../../../db/database';
|
||||
import { pluginCodeDir, pluginsCodeRoot, pluginsDataRoot } from '../paths';
|
||||
import { safeDownload, sha256Matches } from '../install/safe-fetch';
|
||||
import { verifyAuthorSignature } from '../install/verify-signature';
|
||||
import { extractArchive } from '../install/safe-extract';
|
||||
import { scanForNativeBinaries } from '../install/native-scan';
|
||||
import { parseManifest } from '../install/manifest';
|
||||
@@ -36,6 +37,8 @@ interface RegistryVersion {
|
||||
apiVersion?: number;
|
||||
nativeModules?: boolean;
|
||||
publishedAt?: string;
|
||||
/** base64 minisign signature (.minisig payload) over the artifact bytes. */
|
||||
signature?: string;
|
||||
}
|
||||
export interface RegistryEntry {
|
||||
id: string;
|
||||
@@ -47,6 +50,8 @@ export interface RegistryEntry {
|
||||
tags?: string[];
|
||||
type: string;
|
||||
reviewedAt?: string | null;
|
||||
/** base64 minisign author public key — stable across versions, TOFU-pinned. */
|
||||
authorPublicKey?: string;
|
||||
versions: RegistryVersion[];
|
||||
}
|
||||
interface Registry {
|
||||
@@ -160,6 +165,11 @@ export class PluginRegistryService {
|
||||
const { bytes, sha256 } = await safeDownload(ver.downloadUrl, (ver.size ?? 50 * 1024 * 1024) + 4096);
|
||||
if (!sha256Matches(sha256, ver.sha256)) throw new RegistryError('integrity check failed (sha256 mismatch)');
|
||||
|
||||
// 2b. author signature (opt-in) + TOFU key pin. Unsigned plugins skip this
|
||||
// and install on sha256 alone (unchanged behaviour); a signed plugin must
|
||||
// verify, and its author key must match the one pinned on first install.
|
||||
this.verifySignatureAndTofu(id, bytes, entry, ver);
|
||||
|
||||
// 3. zip/tar-slip-safe extract to staging
|
||||
const staging = path.join(pluginsDataRoot(), '.staging', `${id}-${ver.version}-${Date.now()}`);
|
||||
try {
|
||||
@@ -183,11 +193,44 @@ export class PluginRegistryService {
|
||||
db.prepare('UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?').run(
|
||||
entry.repo, ver.commitSha, ver.sha256, entry.reviewedAt ?? null, id,
|
||||
);
|
||||
// Pin the author key on first successful install of a signed plugin (TOFU).
|
||||
if (entry.authorPublicKey) {
|
||||
db.prepare('UPDATE plugins SET author_pubkey = ? WHERE id = ?').run(entry.authorPublicKey, id);
|
||||
}
|
||||
return { id, version: ver.version };
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the author signature (if the entry declares a key + the version a
|
||||
* signature) and enforce Trust-On-First-Use on the author key.
|
||||
*
|
||||
* - Neither key nor signature → unsigned plugin, skip (opt-in).
|
||||
* - Key present but no signature (or vice versa) → hard stop; a half-signed
|
||||
* entry is a misconfiguration we refuse rather than silently downgrade.
|
||||
* - Signature invalid → hard stop.
|
||||
* - Registry key differs from the key pinned on a prior install → hard stop
|
||||
* (author change / rotation / attack; needs an explicit admin re-trust).
|
||||
*/
|
||||
private verifySignatureAndTofu(id: string, bytes: Buffer, entry: RegistryEntry, ver: RegistryVersion): void {
|
||||
const pinned = (db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string } | undefined)?.author_pubkey ?? null;
|
||||
|
||||
if (!entry.authorPublicKey && !ver.signature) {
|
||||
if (pinned) throw new RegistryError('this plugin was signed before but the update is unsigned — refusing');
|
||||
return; // unsigned throughout: sha256 is the only pin
|
||||
}
|
||||
if (!entry.authorPublicKey || !ver.signature) {
|
||||
throw new RegistryError('incomplete signature: an author key and a version signature must both be present');
|
||||
}
|
||||
if (pinned && pinned !== entry.authorPublicKey) {
|
||||
throw new RegistryError("the plugin's author signing key changed since it was installed — re-trust it explicitly to continue");
|
||||
}
|
||||
if (!verifyAuthorSignature(bytes, ver.signature, entry.authorPublicKey)) {
|
||||
throw new RegistryError('author signature verification failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rawFileUrl(repo: string, commitSha: string, file: string): string {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createPluginContext, type ChildTransport, type PluginContext, type PluginDefinition } from './plugin-sdk';
|
||||
import type { Envelope, RpcError } from '../protocol/envelope';
|
||||
@@ -18,6 +19,8 @@ import type { Envelope, RpcError } from '../protocol/envelope';
|
||||
const pluginId = process.argv[2] || process.env.TREK_PLUGIN_ID || 'unknown';
|
||||
const pluginDir = process.argv[3] || '';
|
||||
|
||||
let pluginConfig: Record<string, unknown> = {};
|
||||
|
||||
const pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
||||
let seq = 0;
|
||||
|
||||
@@ -73,18 +76,21 @@ async function handleInvoke(req: { id: string; method: string; params: Record<st
|
||||
);
|
||||
try {
|
||||
if (!def || !ctx) throw new Error('plugin not loaded');
|
||||
// A per-invocation ctx tagged with this invoke's id, so the host binds trip
|
||||
// reads to the invocation's authenticated user (routes) / refuses them (jobs).
|
||||
const invCtx = createPluginContext(pluginId, pluginConfig, transport, req.id);
|
||||
if (req.method === 'invoke.route') {
|
||||
const routeId = req.params.routeId as number;
|
||||
const route = def.routes?.[routeId];
|
||||
if (!route) throw new Error(`no route ${routeId}`);
|
||||
const pluginReq = req.params.req as Parameters<NonNullable<typeof route.handler>>[0];
|
||||
const result = await route.handler(pluginReq, ctx);
|
||||
const result = await route.handler(pluginReq, invCtx);
|
||||
respond(true, result);
|
||||
} else if (req.method === 'invoke.job') {
|
||||
const jobId = req.params.jobId as string;
|
||||
const job = def.jobs?.find((j) => j.id === jobId);
|
||||
if (!job) throw new Error(`no job ${jobId}`);
|
||||
await job.handler(ctx);
|
||||
await job.handler(invCtx);
|
||||
respond(true, { ok: true });
|
||||
} else {
|
||||
respond(false, `unknown invoke ${req.method}`);
|
||||
@@ -127,17 +133,26 @@ process.on('message', (raw: unknown) => {
|
||||
if (msg.k === 'evt') {
|
||||
if (msg.topic === 'init') {
|
||||
const d = msg.data as { config?: Record<string, unknown>; egress?: string[] };
|
||||
pluginConfig = d.config ?? {};
|
||||
installEgressGuard(d.egress ?? []);
|
||||
void boot(d.config ?? {});
|
||||
void boot(pluginConfig);
|
||||
} else if (msg.topic === 'shutdown') void shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Restrict the plugin's outbound network to its declared egress hosts. With no
|
||||
* declared egress, ALL outbound fetch is blocked. Wildcards like `*.host` match
|
||||
* any subdomain. This is process-level defense in depth; the container runtime
|
||||
* (v2) enforces it at the network layer.
|
||||
* declared egress, ALL outbound is blocked. Wildcards like `*.host` match any
|
||||
* subdomain.
|
||||
*
|
||||
* Two layers, so a plugin can't just sidestep `fetch` with a raw socket:
|
||||
* 1. `globalThis.fetch` is wrapped (undici path).
|
||||
* 2. `net.Socket.prototype.connect` is wrapped — the single TCP choke point that
|
||||
* node:http / node:https / node:net / node:tls all funnel through — so
|
||||
* `require('node:https').request(...)` is subject to the same allowlist.
|
||||
* Under the OS permission model the child also cannot spawn a fresh process or
|
||||
* load a native addon to escape these wrappers. This is strong defense in depth;
|
||||
* a kernel/network-namespace guarantee still belongs to the container runtime.
|
||||
*/
|
||||
function installEgressGuard(egress: string[]): void {
|
||||
const patterns = egress.map((h) => h.trim().toLowerCase()).filter(Boolean);
|
||||
@@ -145,19 +160,42 @@ function installEgressGuard(egress: string[]): void {
|
||||
const h = hostname.toLowerCase();
|
||||
return patterns.some((p) => (p.startsWith('*.') ? h === p.slice(2) || h.endsWith(p.slice(1)) : h === p));
|
||||
};
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
if (typeof realFetch !== 'function') return;
|
||||
globalThis.fetch = ((input: unknown, init?: unknown) => {
|
||||
const url = typeof input === 'string' ? input : (input as { url?: string })?.url ?? String(input);
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(url).hostname;
|
||||
} catch {
|
||||
return Promise.reject(new Error('egress: invalid url'));
|
||||
if (typeof realFetch === 'function') {
|
||||
globalThis.fetch = ((input: unknown, init?: unknown) => {
|
||||
const url = typeof input === 'string' ? input : (input as { url?: string })?.url ?? String(input);
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(url).hostname;
|
||||
} catch {
|
||||
return Promise.reject(new Error('egress: invalid url'));
|
||||
}
|
||||
if (!allowed(host)) return Promise.reject(new Error(`egress: ${host} is not in the plugin's declared hosts`));
|
||||
return (realFetch as (i: unknown, n: unknown) => Promise<unknown>)(input, init);
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
// TCP choke point: http/https/net/tls all end up here. A unix-socket (path)
|
||||
// connect is local IPC, not network egress, so it's left alone.
|
||||
const proto = net.Socket.prototype as unknown as { connect: (...a: unknown[]) => unknown };
|
||||
const realConnect = proto.connect;
|
||||
proto.connect = function (this: unknown, ...args: unknown[]): unknown {
|
||||
const first = args[0];
|
||||
let host: string | undefined;
|
||||
if (first && typeof first === 'object') {
|
||||
const o = first as { host?: string; path?: string };
|
||||
if (o.path) return realConnect.apply(this, args); // unix socket — local
|
||||
host = o.host ?? 'localhost';
|
||||
} else if (typeof first === 'number' || typeof first === 'string') {
|
||||
// connect(port[, host]) — a numeric-only path connects to localhost.
|
||||
host = typeof args[1] === 'string' ? (args[1] as string) : 'localhost';
|
||||
}
|
||||
if (!allowed(host)) return Promise.reject(new Error(`egress: ${host} is not in the plugin's declared hosts`));
|
||||
return (realFetch as (i: unknown, n: unknown) => Promise<unknown>)(input, init);
|
||||
}) as typeof fetch;
|
||||
if (host && !allowed(host)) {
|
||||
throw new Error(`egress: ${host} is not in the plugin's declared hosts`);
|
||||
}
|
||||
return realConnect.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
// Ask the host for the init payload (instance config), then wait for it.
|
||||
|
||||
@@ -17,9 +17,14 @@ export interface PluginContext {
|
||||
migrate(id: string, sql: string): Promise<{ applied: boolean }>;
|
||||
};
|
||||
trips: {
|
||||
getById(tripId: number, asUserId: number): Promise<unknown>;
|
||||
getPlaces(tripId: number, asUserId: number): Promise<unknown[]>;
|
||||
getReservations(tripId: number, asUserId: number): Promise<unknown[]>;
|
||||
// `asUserId` is accepted for source compatibility but IGNORED by the host —
|
||||
// trip reads are always membership-checked against the authenticated user of
|
||||
// the current invocation (the request's `req.user`), which the plugin cannot
|
||||
// override. Only reachable from a route handler (a user context); a job has
|
||||
// no user and its trip reads are refused.
|
||||
getById(tripId: number, asUserId?: number): Promise<unknown>;
|
||||
getPlaces(tripId: number, asUserId?: number): Promise<unknown[]>;
|
||||
getReservations(tripId: number, asUserId?: number): Promise<unknown[]>;
|
||||
};
|
||||
users: {
|
||||
getById(id: number): Promise<unknown>;
|
||||
@@ -77,11 +82,18 @@ export interface ChildTransport {
|
||||
emit(topic: string, data: unknown): void;
|
||||
}
|
||||
|
||||
/** Build the ctx the plugin's handlers receive — every method is an RPC call. */
|
||||
/**
|
||||
* Build the ctx the plugin's handlers receive — every method is an RPC call.
|
||||
* `invocationId` (the host's reqId for the route/job currently being handled) is
|
||||
* attached to trip reads as `_inv` so the host can bind the acting user to THIS
|
||||
* invocation. It is undefined for the load-time ctx (onLoad), where trip reads
|
||||
* have no user and are refused.
|
||||
*/
|
||||
export function createPluginContext(
|
||||
id: string,
|
||||
config: Record<string, unknown>,
|
||||
t: ChildTransport,
|
||||
invocationId?: string,
|
||||
): PluginContext {
|
||||
return {
|
||||
id,
|
||||
@@ -92,9 +104,9 @@ export function createPluginContext(
|
||||
migrate: (mid, sql) => t.rpc('db.migrate', { id: mid, sql }) as Promise<{ applied: boolean }>,
|
||||
},
|
||||
trips: {
|
||||
getById: (tripId, asUserId) => t.rpc('trips.getById', { tripId, asUserId }),
|
||||
getPlaces: (tripId, asUserId) => t.rpc('trips.getPlaces', { tripId, asUserId }) as Promise<unknown[]>,
|
||||
getReservations: (tripId, asUserId) => t.rpc('trips.getReservations', { tripId, asUserId }) as Promise<unknown[]>,
|
||||
getById: (tripId) => t.rpc('trips.getById', { tripId, _inv: invocationId }),
|
||||
getPlaces: (tripId) => t.rpc('trips.getPlaces', { tripId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
getReservations: (tripId) => t.rpc('trips.getReservations', { tripId, _inv: invocationId }) as Promise<unknown[]>,
|
||||
},
|
||||
users: {
|
||||
getById: (uid) => t.rpc('users.getById', { id: uid }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { fork, type ChildProcess } from 'node:child_process';
|
||||
import { resolveChildEntry, pluginCodeDir } from '../paths';
|
||||
import { resolveChildEntry, pluginCodeDir, pluginPermissionArgs } from '../paths';
|
||||
import type { Envelope, RpcError, RpcRequest } from '../protocol/envelope';
|
||||
import type { PluginRpcHost } from '../host/rpc-host';
|
||||
|
||||
@@ -48,6 +48,7 @@ interface Supervised {
|
||||
routes: PluginRouteInfo[];
|
||||
jobs: string[];
|
||||
pending: Map<string, Pending>; // host→child invokes awaiting a response
|
||||
invocations: Map<string, number | undefined>; // reqId -> acting user of that invoke (undefined = no user, e.g. a job)
|
||||
activation?: { resolve: () => void; reject: (e: Error) => void };
|
||||
}
|
||||
|
||||
@@ -96,6 +97,7 @@ export class PluginSupervisor {
|
||||
routes: [],
|
||||
jobs: [],
|
||||
pending: new Map(),
|
||||
invocations: new Map(),
|
||||
};
|
||||
this.running.set(id, sup);
|
||||
this.ensureSweep();
|
||||
@@ -126,8 +128,20 @@ export class PluginSupervisor {
|
||||
return this.running.get(id)?.routes ?? [];
|
||||
}
|
||||
|
||||
/** Send a host→child request (invoke a route or job) and await its response. */
|
||||
invoke(id: string, method: string, params: Record<string, unknown>, timeoutMs = 30_000): Promise<unknown> {
|
||||
/**
|
||||
* Send a host→child request (invoke a route or job) and await its response.
|
||||
* `actingUserId` binds the authenticated user of this invocation on the HOST
|
||||
* side: any trip read the child makes while handling it is membership-checked
|
||||
* against THIS user, never against an id the plugin passes. A job carries no
|
||||
* user (undefined) and therefore cannot read user-scoped data.
|
||||
*/
|
||||
invoke(
|
||||
id: string,
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
opts: { timeoutMs?: number; actingUserId?: number } = {},
|
||||
): Promise<unknown> {
|
||||
const { timeoutMs = 30_000, actingUserId } = opts;
|
||||
const sup = this.running.get(id);
|
||||
if (!sup || sup.status !== 'active' || !sup.child) {
|
||||
return Promise.reject(new Error(`plugin ${id} is not active`));
|
||||
@@ -136,11 +150,15 @@ export class PluginSupervisor {
|
||||
const reqId = randomUUID();
|
||||
const timer = setTimeout(() => {
|
||||
sup.pending.delete(reqId);
|
||||
sup.invocations.delete(reqId);
|
||||
reject(new Error('plugin invoke timed out'));
|
||||
}, timeoutMs);
|
||||
timer.unref?.();
|
||||
sup.pending.set(reqId, { resolve, reject, timer });
|
||||
sup.child!.send({ k: 'req', id: reqId, method, params } satisfies Envelope);
|
||||
// The child echoes this reqId as `_inv` on its trip reads; the host resolves
|
||||
// the acting user from here, so the plugin cannot name an arbitrary user.
|
||||
sup.invocations.set(reqId, actingUserId);
|
||||
sup.child!.send({ k: 'req', id: reqId, method, params: { ...params, _inv: reqId } } satisfies Envelope);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,10 +174,13 @@ export class PluginSupervisor {
|
||||
// ── internals ──────────────────────────────────────────────────────────────
|
||||
|
||||
private spawn(sup: Supervised): void {
|
||||
const { entry, execArgv, forkCwd } = resolveChildEntry();
|
||||
const { entry, execArgv, forkCwd, jsMode } = resolveChildEntry();
|
||||
// Prod (compiled) children get the OS permission model — a real kernel-level
|
||||
// fs/child_process/native jail on top of the env scrub and RPC boundary.
|
||||
const argv = jsMode ? [...execArgv, ...pluginPermissionArgs(sup.id)] : execArgv;
|
||||
const child = fork(entry, [sup.id, pluginCodeDir(sup.id)], {
|
||||
cwd: forkCwd ?? pluginCodeDir(sup.id),
|
||||
execArgv,
|
||||
execArgv: argv,
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
// Whitelist env — nothing inherited. No JWT_SECRET, no DB creds, no PATH-leaked secrets.
|
||||
env: {
|
||||
@@ -183,8 +204,13 @@ export class PluginSupervisor {
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
|
||||
if (msg.k === 'req') {
|
||||
// A ctx.* call from the plugin — dispatch through its capability host.
|
||||
const res = await sup.rpcHost.dispatch(msg as RpcRequest);
|
||||
// A ctx.* call from the plugin — dispatch through its capability host. The
|
||||
// acting user comes from the invocation the child is currently handling
|
||||
// (its `_inv` reqId → our invocation map), NOT from anything the plugin can
|
||||
// set in the call params.
|
||||
const inv = (msg as RpcRequest).params as { _inv?: unknown } | undefined;
|
||||
const actingUserId = typeof inv?._inv === 'string' ? sup.invocations.get(inv._inv) : undefined;
|
||||
const res = await sup.rpcHost.dispatch(msg as RpcRequest, actingUserId);
|
||||
sup.child?.send(res);
|
||||
return;
|
||||
}
|
||||
@@ -194,6 +220,7 @@ export class PluginSupervisor {
|
||||
const p = sup.pending.get(msg.id);
|
||||
if (!p) return;
|
||||
sup.pending.delete(msg.id);
|
||||
sup.invocations.delete(msg.id);
|
||||
clearTimeout(p.timer);
|
||||
if (msg.ok) p.resolve(msg.result);
|
||||
else p.reject(new Error((msg as RpcError).error.message));
|
||||
@@ -244,6 +271,7 @@ export class PluginSupervisor {
|
||||
p.reject(new Error(reason));
|
||||
}
|
||||
sup.pending.clear();
|
||||
sup.invocations.clear();
|
||||
}
|
||||
|
||||
private onExit(sup: Supervised, code: number | null, signal: string | null): void {
|
||||
|
||||
@@ -8,7 +8,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, generateKeyPairSync, sign as edSign } from 'node:crypto';
|
||||
|
||||
const { safeDownload } = vi.hoisted(() => ({ safeDownload: vi.fn() }));
|
||||
vi.mock('../../../src/nest/plugins/install/safe-fetch', async (orig) => ({
|
||||
@@ -22,7 +22,7 @@ const { testDb } = vi.hoisted(() => {
|
||||
db.exec(`
|
||||
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
|
||||
api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', granted_permissions TEXT, status TEXT, config TEXT,
|
||||
source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, updated_at TEXT);
|
||||
source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, author_pubkey TEXT, updated_at TEXT);
|
||||
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
|
||||
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
|
||||
return { testDb: db };
|
||||
@@ -86,12 +86,31 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
safeDownload.mockReset();
|
||||
delete (REGISTRY.plugins[0] as { authorPublicKey?: string }).authorPublicKey;
|
||||
delete (REGISTRY.plugins[0].versions[0] as { signature?: string }).signature;
|
||||
delete process.env.TREK_PLUGINS_DATA_DIR;
|
||||
delete process.env.TREK_PLUGINS_DIR;
|
||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
||||
fs.rmSync(codeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Ed25519 keypair as (raw 32-byte pubkey base64, raw-signature signer). */
|
||||
function signingKey() {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
return {
|
||||
pubB64: (publicKey.export({ format: 'der', type: 'spki' }).subarray(-32) as Buffer).toString('base64'),
|
||||
sign: (b: Buffer) => (edSign(null, b, privateKey) as Buffer).toString('base64'),
|
||||
};
|
||||
}
|
||||
function stageSignedArtifact(pubB64: string, sig: (b: Buffer) => string): Buffer {
|
||||
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', permissions: ['db:own'] });
|
||||
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(artifact).digest('hex');
|
||||
(REGISTRY.plugins[0] as { authorPublicKey?: string }).authorPublicKey = pubB64;
|
||||
(REGISTRY.plugins[0].versions[0] as { signature?: string }).signature = sig(artifact);
|
||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: REGISTRY.plugins[0].versions[0].sha256 });
|
||||
return artifact;
|
||||
}
|
||||
|
||||
describe('PluginRegistryService', () => {
|
||||
it('browse maps the aggregated registry to metadata', async () => {
|
||||
const list = await svc.browse();
|
||||
@@ -235,4 +254,48 @@ describe('PluginRegistryService', () => {
|
||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: REGISTRY.plugins[0].versions[0].sha256 });
|
||||
await expect(svc.install('flight-tracker')).rejects.toThrow(/!=/);
|
||||
});
|
||||
|
||||
it('installs a signed plugin and pins the author key (TOFU)', async () => {
|
||||
const k = signingKey();
|
||||
stageSignedArtifact(k.pubB64, k.sign);
|
||||
await expect(svc.install('flight-tracker')).resolves.toEqual({ id: 'flight-tracker', version: '1.0.0' });
|
||||
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='flight-tracker'").get() as { author_pubkey: string };
|
||||
expect(row.author_pubkey).toBe(k.pubB64);
|
||||
});
|
||||
|
||||
it('rejects a signed plugin whose signature does not verify', async () => {
|
||||
const k = signingKey();
|
||||
stageSignedArtifact(k.pubB64, () => k.sign(Buffer.from('different bytes')));
|
||||
await expect(svc.install('flight-tracker')).rejects.toThrow(/signature verification failed/);
|
||||
});
|
||||
|
||||
it('rejects an update whose author key differs from the pinned one (TOFU mismatch)', async () => {
|
||||
const a = signingKey();
|
||||
stageSignedArtifact(a.pubB64, a.sign);
|
||||
await svc.install('flight-tracker');
|
||||
const b = signingKey();
|
||||
stageSignedArtifact(b.pubB64, b.sign); // valid signature, but a different author key
|
||||
await expect(svc.install('flight-tracker')).rejects.toThrow(/author signing key changed/);
|
||||
});
|
||||
|
||||
it('rejects a half-signed entry (key without a signature)', async () => {
|
||||
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', permissions: ['db:own'] });
|
||||
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(artifact).digest('hex');
|
||||
(REGISTRY.plugins[0] as { authorPublicKey?: string }).authorPublicKey = signingKey().pubB64; // no version signature
|
||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: REGISTRY.plugins[0].versions[0].sha256 });
|
||||
await expect(svc.install('flight-tracker')).rejects.toThrow(/incomplete signature/);
|
||||
});
|
||||
|
||||
it('refuses to downgrade a previously-signed plugin to an unsigned update', async () => {
|
||||
const k = signingKey();
|
||||
stageSignedArtifact(k.pubB64, k.sign);
|
||||
await svc.install('flight-tracker');
|
||||
// now offer an unsigned update
|
||||
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', permissions: ['db:own'] });
|
||||
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(artifact).digest('hex');
|
||||
delete (REGISTRY.plugins[0] as { authorPublicKey?: string }).authorPublicKey;
|
||||
delete (REGISTRY.plugins[0].versions[0] as { signature?: string }).signature;
|
||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: REGISTRY.plugins[0].versions[0].sha256 });
|
||||
await expect(svc.install('flight-tracker')).rejects.toThrow(/unsigned/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,9 @@ function fakeTransport() {
|
||||
describe('createPluginContext', () => {
|
||||
it('maps each ctx method onto the right RPC call', async () => {
|
||||
const { transport, rpc } = fakeTransport();
|
||||
const ctx = createPluginContext('p', {}, transport);
|
||||
// An invocation-scoped ctx: trip reads carry `_inv` so the host can bind the
|
||||
// acting user to this invocation.
|
||||
const ctx = createPluginContext('p', {}, transport, 'inv-1');
|
||||
|
||||
await ctx.db.query('SELECT 1', 'a');
|
||||
expect(rpc).toHaveBeenCalledWith('db.query', { sql: 'SELECT 1', args: ['a'] });
|
||||
@@ -24,8 +26,8 @@ describe('createPluginContext', () => {
|
||||
await ctx.db.migrate('001', 'CREATE TABLE t (x)');
|
||||
expect(rpc).toHaveBeenCalledWith('db.migrate', { id: '001', sql: 'CREATE TABLE t (x)' });
|
||||
|
||||
await ctx.trips.getById(1, 42);
|
||||
expect(rpc).toHaveBeenCalledWith('trips.getById', { tripId: 1, asUserId: 42 });
|
||||
await ctx.trips.getById(1);
|
||||
expect(rpc).toHaveBeenCalledWith('trips.getById', { tripId: 1, _inv: 'inv-1' });
|
||||
|
||||
await ctx.ws.broadcastToTrip(1, 'ping', { a: 1 });
|
||||
expect(rpc).toHaveBeenCalledWith('ws.broadcastToTrip', { tripId: 1, event: 'ping', data: { a: 1 } });
|
||||
@@ -36,11 +38,11 @@ describe('createPluginContext', () => {
|
||||
await ctx.db.exec('DELETE FROM t');
|
||||
expect(rpc).toHaveBeenCalledWith('db.exec', { sql: 'DELETE FROM t', args: [] });
|
||||
|
||||
await ctx.trips.getPlaces(1, 42);
|
||||
expect(rpc).toHaveBeenCalledWith('trips.getPlaces', { tripId: 1, asUserId: 42 });
|
||||
await ctx.trips.getPlaces(1);
|
||||
expect(rpc).toHaveBeenCalledWith('trips.getPlaces', { tripId: 1, _inv: 'inv-1' });
|
||||
|
||||
await ctx.trips.getReservations(1, 42);
|
||||
expect(rpc).toHaveBeenCalledWith('trips.getReservations', { tripId: 1, asUserId: 42 });
|
||||
await ctx.trips.getReservations(1);
|
||||
expect(rpc).toHaveBeenCalledWith('trips.getReservations', { tripId: 1, _inv: 'inv-1' });
|
||||
|
||||
await ctx.ws.broadcastToUser(9, 'poke', { x: 2 });
|
||||
expect(rpc).toHaveBeenCalledWith('ws.broadcastToUser', { userId: 9, event: 'poke', data: { x: 2 } });
|
||||
|
||||
@@ -81,11 +81,12 @@ describe('PluginsProxyController', () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('application/json');
|
||||
expect(res.body).toBe('{"ok":true}');
|
||||
// the child receives only a whitelisted user view, never the token/cookie
|
||||
// the child receives only a whitelisted user view, never the token/cookie;
|
||||
// the acting user (5) is bound server-side as the 4th invoke arg
|
||||
expect(runtime.invoke).toHaveBeenCalledWith('p', 'invoke.route', expect.objectContaining({
|
||||
routeId: 0,
|
||||
req: expect.objectContaining({ user: { id: 5, username: 'ada', isAdmin: false } }),
|
||||
}));
|
||||
}), 5);
|
||||
});
|
||||
|
||||
it('a public (auth:false) route skips the session check', async () => {
|
||||
@@ -94,9 +95,10 @@ describe('PluginsProxyController', () => {
|
||||
await new PluginsProxyController(runtime).proxy('p', fakeReq('POST', '/webhook'), res as never);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(extractTokenMock).not.toHaveBeenCalled();
|
||||
// a public route has no session user → no bound acting user (undefined)
|
||||
expect(runtime.invoke).toHaveBeenCalledWith('p', 'invoke.route', expect.objectContaining({
|
||||
req: expect.objectContaining({ user: null }),
|
||||
}));
|
||||
}), undefined);
|
||||
});
|
||||
|
||||
it('strips unsafe response headers from the child', async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { isKnownPermission, METHOD_PERMISSION, KNOWN_METHODS } from '../../../src/nest/plugins/protocol/envelope';
|
||||
import path from 'node:path';
|
||||
import { pluginsCodeRoot, pluginsDataRoot, pluginCodeDir, pluginDbFile, resolveChildEntry } from '../../../src/nest/plugins/paths';
|
||||
import { pluginsCodeRoot, pluginsDataRoot, pluginCodeDir, pluginDbFile, resolveChildEntry, serverCodeRoot, pluginPermissionArgs } from '../../../src/nest/plugins/paths';
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TREK_PLUGINS_DIR;
|
||||
@@ -47,5 +47,25 @@ describe('paths', () => {
|
||||
const r = resolveChildEntry();
|
||||
expect(r.entry).toMatch(/plugin-host-entry\.(js|ts)$/);
|
||||
expect(Array.isArray(r.execArgv)).toBe(true);
|
||||
expect(typeof r.jsMode).toBe('boolean');
|
||||
});
|
||||
|
||||
it('builds scoped OS-permission flags for a plugin child (default on)', () => {
|
||||
delete process.env.TREK_PLUGIN_PERMISSIONS;
|
||||
const args = pluginPermissionArgs('flight-tracker');
|
||||
expect(args).toContain('--permission');
|
||||
// read is scoped to the compiled server dir + this plugin's own code dir…
|
||||
expect(args.some((a) => a === `--allow-fs-read=${serverCodeRoot()}`)).toBe(true);
|
||||
expect(args.some((a) => a === `--allow-fs-read=${pluginCodeDir('flight-tracker')}`)).toBe(true);
|
||||
// …and never grants fs-write / child_process / the data root.
|
||||
expect(args.some((a) => a.startsWith('--allow-fs-write'))).toBe(false);
|
||||
expect(args.some((a) => a.startsWith('--allow-child-process'))).toBe(false);
|
||||
expect(serverCodeRoot()).not.toContain(`${require('node:path').sep}data`);
|
||||
});
|
||||
|
||||
it('lets an operator opt out of the permission model', () => {
|
||||
process.env.TREK_PLUGIN_PERMISSIONS = 'off';
|
||||
expect(pluginPermissionArgs('flight-tracker')).toEqual([]);
|
||||
delete process.env.TREK_PLUGIN_PERMISSIONS;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,7 +64,8 @@ describe('PluginRpcHost — capability enforcement', () => {
|
||||
|
||||
it('db:read:trips reads a trip the acting user can access', async () => {
|
||||
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
||||
const res = await host.dispatch(req('trips.getById', { tripId: 1, asUserId: 42 }));
|
||||
// The acting user is bound by the HOST (2nd dispatch arg), never from params.
|
||||
const res = await host.dispatch(req('trips.getById', { tripId: 1 }), 42);
|
||||
expect(ok(res)).toBe(true);
|
||||
// returns the ACTUAL trip row (title/start_date), not the access-check object
|
||||
expect((res as RpcResponse).result).toMatchObject({ id: 1, title: 'Japan', start_date: '2027-01-01' });
|
||||
@@ -73,17 +74,25 @@ describe('PluginRpcHost — capability enforcement', () => {
|
||||
|
||||
it('db:read:trips is still RESOURCE_FORBIDDEN when the user is not a member', async () => {
|
||||
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
||||
const res = await host.dispatch(req('trips.getById', { tripId: 1, asUserId: 99 }));
|
||||
const res = await host.dispatch(req('trips.getById', { tripId: 1 }), 99);
|
||||
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
||||
});
|
||||
|
||||
it('a trip read with NO bound acting user is RESOURCE_FORBIDDEN (jobs / forged calls)', async () => {
|
||||
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
||||
// A plugin-supplied asUserId is ignored; without a host-bound user, deny.
|
||||
const res = await host.dispatch(req('trips.getById', { tripId: 1, asUserId: 42 }), undefined);
|
||||
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
||||
expect(deps.canAccessTrip).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('trips.getPlaces is membership-checked before the core read', async () => {
|
||||
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
||||
const forbidden = await host.dispatch(req('trips.getPlaces', { tripId: 2, asUserId: 42 }));
|
||||
const forbidden = await host.dispatch(req('trips.getPlaces', { tripId: 2 }), 42);
|
||||
expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
|
||||
expect(deps.db.prepare).not.toHaveBeenCalled();
|
||||
|
||||
const allowed = await host.dispatch(req('trips.getPlaces', { tripId: 1, asUserId: 42 }));
|
||||
const allowed = await host.dispatch(req('trips.getPlaces', { tripId: 1 }), 42);
|
||||
expect(ok(allowed)).toBe(true);
|
||||
expect((allowed as RpcResponse).result).toEqual([{ id: 7, name: 'Place' }]);
|
||||
});
|
||||
@@ -142,7 +151,7 @@ describe('PluginRpcHost — capability enforcement', () => {
|
||||
|
||||
it('trips.getReservations is membership-checked and reads reservations', async () => {
|
||||
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
|
||||
const res = await host.dispatch(req('trips.getReservations', { tripId: 1, asUserId: 42 }));
|
||||
const res = await host.dispatch(req('trips.getReservations', { tripId: 1 }), 42);
|
||||
expect(ok(res)).toBe(true);
|
||||
expect(deps.db.prepare).toHaveBeenCalledWith(expect.stringContaining('FROM reservations'));
|
||||
});
|
||||
@@ -169,8 +178,8 @@ describe('PluginRpcHost — capability enforcement', () => {
|
||||
|
||||
it('coerces numeric string params and tolerates a missing params object', async () => {
|
||||
const host = new PluginRpcHost('p', new Set(['db:read:trips', 'db:own']), deps);
|
||||
// tripId/asUserId as strings -> coerced to numbers
|
||||
const res = await host.dispatch(req('trips.getById', { tripId: '1', asUserId: '42' }));
|
||||
// tripId as a string -> coerced to a number; acting user is the bound host arg
|
||||
const res = await host.dispatch(req('trips.getById', { tripId: '1' }), 42);
|
||||
expect(ok(res)).toBe(true);
|
||||
// a request with no params object at all -> BAD_PARAMS (sql missing), not a crash
|
||||
const noParams = await host.dispatch({ k: 'req', id: 'y', method: 'db.query', params: undefined });
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Author-signature verification (#plugins, #4). Ed25519 via node:crypto, in the
|
||||
* minisign shapes TREK accepts: bare raw key+sig, minisign legacy ('Ed', signs
|
||||
* the file), and minisign prehashed ('ED', signs BLAKE2b-512 of the file).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import crypto from 'node:crypto';
|
||||
import { verifyAuthorSignature, SignatureError } from '../../../src/nest/plugins/install/verify-signature';
|
||||
|
||||
// A raw Ed25519 keypair, exported as the 32-byte raw public key + a signer.
|
||||
function makeKeypair() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
||||
const rawPub = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32); // last 32 bytes = the key
|
||||
return {
|
||||
rawPub,
|
||||
sign: (msg: Buffer) => crypto.sign(null, msg, privateKey),
|
||||
};
|
||||
}
|
||||
|
||||
const KEY_ID = Buffer.from('0011223344556677', 'hex');
|
||||
const b64 = (b: Buffer) => b.toString('base64');
|
||||
|
||||
describe('verifyAuthorSignature', () => {
|
||||
const bytes = Buffer.from('the exact plugin.zip bytes');
|
||||
|
||||
it('accepts a bare raw Ed25519 key + 64-byte signature over the file', () => {
|
||||
const { rawPub, sign } = makeKeypair();
|
||||
expect(verifyAuthorSignature(bytes, b64(sign(bytes)), b64(rawPub))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a valid signature from a different key', () => {
|
||||
const a = makeKeypair();
|
||||
const b = makeKeypair();
|
||||
expect(verifyAuthorSignature(bytes, b64(a.sign(bytes)), b64(b.rawPub))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a signature over tampered bytes', () => {
|
||||
const { rawPub, sign } = makeKeypair();
|
||||
const sig = b64(sign(bytes));
|
||||
expect(verifyAuthorSignature(Buffer.from('tampered'), sig, b64(rawPub))).toBe(false);
|
||||
});
|
||||
|
||||
it('verifies the minisign legacy format (Ed — signs the raw file)', () => {
|
||||
const { rawPub, sign } = makeKeypair();
|
||||
const pub = b64(Buffer.concat([Buffer.from('Ed'), KEY_ID, rawPub]));
|
||||
const sig = b64(Buffer.concat([Buffer.from('Ed'), KEY_ID, sign(bytes)]));
|
||||
expect(verifyAuthorSignature(bytes, sig, pub)).toBe(true);
|
||||
});
|
||||
|
||||
it('verifies the minisign prehashed format (ED — signs BLAKE2b-512 of the file)', () => {
|
||||
const { rawPub, sign } = makeKeypair();
|
||||
const hash = crypto.createHash('blake2b512').update(bytes).digest();
|
||||
const pub = b64(Buffer.concat([Buffer.from('Ed'), KEY_ID, rawPub]));
|
||||
const sig = b64(Buffer.concat([Buffer.from('ED'), KEY_ID, sign(hash)]));
|
||||
expect(verifyAuthorSignature(bytes, sig, pub)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when the key ids in key and signature disagree', () => {
|
||||
const { rawPub, sign } = makeKeypair();
|
||||
const pub = b64(Buffer.concat([Buffer.from('Ed'), KEY_ID, rawPub]));
|
||||
const otherId = Buffer.from('8899aabbccddeeff', 'hex');
|
||||
const sig = b64(Buffer.concat([Buffer.from('Ed'), otherId, sign(bytes)]));
|
||||
expect(verifyAuthorSignature(bytes, sig, pub)).toBe(false);
|
||||
});
|
||||
|
||||
it('parses a two-line minisign key (comment + base64)', () => {
|
||||
const { rawPub, sign } = makeKeypair();
|
||||
const pub = `untrusted comment: minisign public key ABC\n${b64(Buffer.concat([Buffer.from('Ed'), KEY_ID, rawPub]))}`;
|
||||
const sig = `untrusted comment: signature\n${b64(Buffer.concat([Buffer.from('Ed'), KEY_ID, sign(bytes)]))}`;
|
||||
expect(verifyAuthorSignature(bytes, sig, pub)).toBe(true);
|
||||
});
|
||||
|
||||
it('throws SignatureError on a malformed key', () => {
|
||||
expect(() => verifyAuthorSignature(bytes, b64(Buffer.alloc(64)), b64(Buffer.alloc(7)))).toThrow(SignatureError);
|
||||
});
|
||||
|
||||
it('throws SignatureError on a malformed signature', () => {
|
||||
const { rawPub } = makeKeypair();
|
||||
expect(() => verifyAuthorSignature(bytes, b64(Buffer.alloc(10)), b64(rawPub))).toThrow(SignatureError);
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,49 @@ TREK verifies the downloaded bytes against `sha256` and refuses to install on a
|
||||
mismatch. A `reviewedAt` date on your entry means a maintainer looked at that
|
||||
exact commit — it is **not** an ongoing guarantee.
|
||||
|
||||
## Signing your releases (optional, recommended)
|
||||
|
||||
`sha256` proves the bytes are the ones the *registry* vouches for. An author
|
||||
signature additionally proves the bytes were signed by **you** — so a compromised
|
||||
registry cannot ship attacker code under your name. TREK verifies the signature
|
||||
offline (Ed25519, no external service), and pins your key on first install
|
||||
(trust-on-first-use): a later release signed with a different key is refused until
|
||||
an admin re-trusts it.
|
||||
|
||||
**One-time — create a key:**
|
||||
|
||||
```
|
||||
minisign -G # writes minisign.key (keep secret) + minisign.pub
|
||||
```
|
||||
|
||||
Put the public key in your registry entry as `authorPublicKey` (the base64 payload
|
||||
line from `minisign.pub`). It is stable across versions.
|
||||
|
||||
**Each release — sign the artifact:**
|
||||
|
||||
```
|
||||
minisign -Sm plugin.zip # writes plugin.zip.minisig
|
||||
```
|
||||
|
||||
Add the base64 signature line from `plugin.zip.minisig` to that version as
|
||||
`signature`, next to its `sha256`. Example entry:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": "flight-tracker",
|
||||
"authorPublicKey": "RWQ…base64 minisign public key…",
|
||||
"versions": [{
|
||||
"version": "1.2.0",
|
||||
"sha256": "3b2a…",
|
||||
"signature": "RUR…base64 .minisig payload…"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Signing is **opt-in**: an entry without `authorPublicKey`/`signature` installs on
|
||||
`sha256` alone, exactly as before. But once a plugin has shipped signed, an
|
||||
*unsigned* update for it is refused — don't drop the signature between versions.
|
||||
|
||||
## Updating
|
||||
|
||||
Add a new version entry (new `version`/`gitTag`/`commitSha`/`sha256`) to your
|
||||
|
||||
Reference in New Issue
Block a user