Files
TREK/server/tests/unit/plugins/plugin-sdk.test.ts
T
Maurice 332e5c3c42 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).
2026-07-04 19:11:23 +02:00

81 lines
3.3 KiB
TypeScript

/**
* The child-side SDK (#plugins, M1). Pure plumbing: every ctx method turns into
* an RPC call, config is frozen, and log is a fire-and-forget event. Runs
* in-process against a fake transport (no fork needed).
*/
import { describe, it, expect, vi } from 'vitest';
import { createPluginContext, definePlugin, type ChildTransport } from '../../../src/nest/plugins/runtime/plugin-sdk';
function fakeTransport() {
const rpc = vi.fn(async () => ({ ok: true }));
const emit = vi.fn();
const transport = { rpc, emit } as unknown as ChildTransport;
return { transport, rpc, emit };
}
describe('createPluginContext', () => {
it('maps each ctx method onto the right RPC call', async () => {
const { transport, rpc } = fakeTransport();
// 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'] });
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);
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 } });
await ctx.users.getById(3);
expect(rpc).toHaveBeenCalledWith('users.getById', { id: 3 });
await ctx.db.exec('DELETE FROM t');
expect(rpc).toHaveBeenCalledWith('db.exec', { sql: 'DELETE FROM t', args: [] });
await ctx.trips.getPlaces(1);
expect(rpc).toHaveBeenCalledWith('trips.getPlaces', { tripId: 1, _inv: 'inv-1' });
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 } });
});
it('log warn and error also emit events', () => {
const { transport, emit } = fakeTransport();
const ctx = createPluginContext('p', {}, transport);
ctx.log.warn('careful');
ctx.log.error('bad', { code: 1 });
expect(emit).toHaveBeenCalledWith('log', { level: 'warn', msg: 'careful', meta: undefined });
expect(emit).toHaveBeenCalledWith('log', { level: 'error', msg: 'bad', meta: { code: 1 } });
});
it('freezes config so a plugin cannot mutate its instance settings object', () => {
const ctx = createPluginContext('p', { api_key: 'secret' }, fakeTransport().transport);
expect(ctx.config.api_key).toBe('secret');
expect(() => {
(ctx.config as Record<string, unknown>).api_key = 'tampered';
}).toThrow();
});
it('log is a fire-and-forget event, not an RPC round-trip', () => {
const { transport, rpc, emit } = fakeTransport();
const ctx = createPluginContext('p', {}, transport);
ctx.log.info('hello', { n: 1 });
expect(emit).toHaveBeenCalledWith('log', { level: 'info', msg: 'hello', meta: { n: 1 } });
expect(rpc).not.toHaveBeenCalled();
});
it('definePlugin returns the definition unchanged', () => {
const def = { onLoad: async () => {} };
expect(definePlugin(def)).toBe(def);
});
});