feat(plugins): zero-config L1 hardening — SSRF egress, RSS reaper, capability audit, no popups

Security that ships from the install itself, no self-hoster setup:

- Egress SSRF/rebinding backstop: the net.Socket connect guard now RESOLVES the
  destination and refuses private/loopback/link-local/metadata/CGNAT/ULA
  addresses, pinning the resolved IP (a declared host that re-resolves to an
  internal address is blocked). Pure policy in egress-policy.ts + tests.
  TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on opts back into internal targets.
- RSS memory reaper: the supervisor now kills a child that blows a real RSS
  ceiling (TREK_PLUGIN_MAX_RSS_MB, default 300) — --max-old-space-size only
  bounds the V8 heap, so Buffers could OOM the box under it.
- Hash-chained capability audit log (migration 159): every core-data / broadcast
  call is recorded at the RPC boundary with the host-bound acting user and a
  per-plugin hash chain, so wide grants stay attributable + tamper-evident.
  Admin endpoint GET /api/admin/plugins/:id/audit.
- Drop allow-popups from the plugin frame (sandbox + CSP): window.open ignores
  connect-src, so it was an egress/phishing bypass.

Server suite 207 green, client + migration reconciliation green.
This commit is contained in:
Maurice
2026-07-04 01:11:48 +02:00
committed by Maurice
parent 73e9ccfd6d
commit 2a88b0c711
15 changed files with 425 additions and 33 deletions
+1
View File
@@ -469,6 +469,7 @@ export const adminApi = {
pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data),
pluginRescan: () => apiClient.post('/admin/plugins/rescan').then(r => r.data),
pluginErrors: (id: string) => apiClient.get(`/admin/plugins/${id}/errors`).then(r => r.data),
pluginAudit: (id: string) => apiClient.get(`/admin/plugins/${id}/audit`).then(r => r.data),
// Local LLM (Ollama) management for the AI-parsing addon.
llmLocalModels: (baseUrl: string): Promise<{ models: { name: string; size: number }[] }> =>
apiClient.get('/admin/llm/local/models', { params: { baseUrl } }).then(r => r.data),
@@ -104,7 +104,7 @@ export default function PluginFrame({ pluginId, tripId = null, className, title
<iframe
ref={frameRef}
src={`/plugin-frame/${pluginId}/index.html`}
sandbox="allow-scripts allow-forms allow-popups"
sandbox="allow-scripts allow-forms"
referrerPolicy="no-referrer"
loading="lazy"
title={title || pluginId}
+21
View File
@@ -3377,6 +3377,27 @@ function runMigrations(db: Database.Database): void {
() => {
try { db.exec("ALTER TABLE plugins ADD COLUMN author_pubkey TEXT;"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
},
// Migration 159: hash-chained capability audit log (#plugins, L1 hardening).
// Every host-mediated capability call the plugin makes is recorded at the RPC
// boundary (where the plugin provably can't reach) with the host-bound acting
// user and a per-plugin hash chain, so wide data grants stay attributable +
// tamper-evident + user-visible.
() => {
try {
db.exec(`CREATE TABLE IF NOT EXISTS plugin_capability_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
acting_user_id INTEGER,
method TEXT NOT NULL,
resource TEXT,
code TEXT NOT NULL,
ts TEXT NOT NULL DEFAULT (datetime('now')),
prev_hash TEXT,
hash TEXT NOT NULL
);`);
db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_audit_plugin ON plugin_capability_audit (plugin_id, id);');
} catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
},
];
if (currentVersion < migrations.length) {
@@ -2,6 +2,7 @@ import { db, canAccessTrip } from '../../../db/database';
import { broadcast, broadcastToUser } from '../../../websocket';
import { PluginDataDb } from './plugin-data.service';
import { PluginRpcHost } from './rpc-host';
import { appendAudit } from './plugin-audit';
/**
* Wires a plugin's capability host to the REAL privileged modules (#plugins,
@@ -33,5 +34,6 @@ export function createRealRpcHost(id: string, granted: ReadonlySet<string>): Plu
canAccessTrip: (tripId, userId) => canAccessTrip(tripId, userId),
broadcastToTrip: (tripId, event, payload) => broadcast(tripId, `plugin:${id}:${event}`, payload),
broadcastToUser: (userId, payload) => broadcastToUser(userId, { type: `plugin:${id}`, ...payload }),
audit: (entry) => appendAudit(db, entry),
});
}
@@ -0,0 +1,63 @@
import crypto from 'node:crypto';
/**
* Host-side, hash-chained capability audit (#plugins, L1 hardening).
*
* Every host-mediated core-data / broadcast call a plugin makes is recorded at
* the RPC boundary — the one place the plugin provably can't reach — with the
* HOST-bound acting user (not a value the plugin supplies) and a per-plugin hash
* chain (hash = sha256(prev_hash + row)). That makes wide data grants
* attributable, tamper-evident, and user-visible, which is exactly what lets
* TREK grant broad reads to large addons without raising risk.
*/
interface AuditDb {
prepare(sql: string): {
get(...args: unknown[]): unknown;
run(...args: unknown[]): unknown;
all(...args: unknown[]): unknown[];
};
}
export interface AuditEntry {
pluginId: string;
actingUserId?: number;
method: string;
resource?: string | null;
code: string;
}
/** The methods worth auditing: core-data reads + broadcasts, not own-db noise. */
export function auditResource(method: string, params: Record<string, unknown>): string | null {
if (method.startsWith('trips.')) return `trip:${params.tripId ?? '?'}`;
if (method === 'users.getById') return `user:${params.id ?? '?'}`;
if (method === 'ws.broadcastToTrip') return `trip:${params.tripId ?? '?'}`;
if (method === 'ws.broadcastToUser') return `user:${params.userId ?? '?'}`;
return null;
}
/** True for calls we record (core data + ws); a plugin's own-db calls are skipped. */
export function isAuditable(method: string): boolean {
return method.startsWith('trips.') || method.startsWith('users.') || method.startsWith('ws.');
}
/** Append one entry to the per-plugin hash chain. Synchronous (better-sqlite3). */
export function appendAudit(db: AuditDb, e: AuditEntry): void {
const prev =
(db.prepare('SELECT hash FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id DESC LIMIT 1').get(e.pluginId) as
| { hash: string }
| undefined)?.hash ?? '';
const ts = new Date().toISOString();
const row = JSON.stringify([e.pluginId, e.actingUserId ?? null, e.method, e.resource ?? null, e.code, ts]);
const hash = crypto.createHash('sha256').update(prev + row).digest('hex');
db.prepare(
'INSERT INTO plugin_capability_audit (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?)',
).run(e.pluginId, e.actingUserId ?? null, e.method, e.resource ?? null, e.code, ts, prev || null, hash);
}
/** Read the most recent audit rows for a plugin (admin view). */
export function readAudit(db: AuditDb, pluginId: string, limit = 200): unknown[] {
return db
.prepare('SELECT ts, acting_user_id, method, resource, code FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id DESC LIMIT ?')
.all(pluginId, limit);
}
+27 -1
View File
@@ -6,6 +6,7 @@ import {
type RpcResponse,
} from '../protocol/envelope';
import type { PluginDataDb } from './plugin-data.service';
import { auditResource, isAuditable } from './plugin-audit';
/**
* The per-plugin capability router (#plugins, M1) — the ENFORCEMENT POINT.
@@ -39,6 +40,8 @@ export interface HostDeps {
broadcastToTrip(tripId: number, eventType: string, payload: Record<string, unknown>): void;
/** Namespaced per-user broadcast. */
broadcastToUser(userId: number, payload: Record<string, unknown>): void;
/** Optional sink for the capability audit log (host-side, hash-chained). */
audit?(entry: { pluginId: string; actingUserId?: number; method: string; resource: string | null; code: string }): void;
}
type Handler = (params: Record<string, unknown>, actingUserId: number | undefined) => unknown;
@@ -123,6 +126,30 @@ export class PluginRpcHost {
}
async dispatch(req: RpcRequest, actingUserId?: number): Promise<RpcResponse | RpcError> {
const params = (req.params ?? {}) as Record<string, unknown>;
const res = await this.handle(req, params, actingUserId);
// Audit the core-data / broadcast surface (incl. denials) at the boundary.
if (this.deps.audit && isAuditable(req.method)) {
try {
this.deps.audit({
pluginId: this.pluginId,
actingUserId,
method: req.method,
resource: auditResource(req.method, params),
code: res.ok ? 'ok' : (res as RpcError).error.code,
});
} catch {
/* auditing must never break a call */
}
}
return res;
}
private async handle(
req: RpcRequest,
params: Record<string, unknown>,
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);
@@ -134,7 +161,6 @@ export class PluginRpcHost {
: `unknown method ${req.method}`,
);
}
const params = (req.params ?? {}) as Record<string, unknown>;
try {
const result = await handler(params, actingUserId);
return { k: 'res', id: req.id, ok: true, result };
@@ -83,7 +83,10 @@ export class PluginFrameController {
"frame-ancestors 'self'",
"base-uri 'none'",
"form-action 'self'",
'sandbox allow-scripts allow-forms allow-popups',
// No allow-popups: window.open() ignores connect-src, so it would be an
// egress/phishing bypass (open any URL / a fake full-page login). A future
// OAuth-popup addon can re-request it as an explicit capability.
'sandbox allow-scripts allow-forms',
].join('; ');
}
}
@@ -95,6 +95,11 @@ export class PluginsController {
return { errors: this.plugins.errors(id) };
}
@Get(':id/audit')
audit(@Param('id') id: string) {
return { audit: this.plugins.auditLog(id) };
}
@Delete(':id/errors')
clearErrors(@Param('id') id: string) {
this.plugins.clearErrors(id);
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { db } from '../../db/database';
import { pluginsEnabled } from './kill-switch';
import { maybe_encrypt_api_key } from '../../services/apiKeyCrypto';
import { readAudit } from './host/plugin-audit';
const SECRET_MASK = '••••••••';
@@ -80,6 +81,11 @@ export class PluginsService {
db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
}
/** A plugin's hash-chained capability audit log, newest first. */
auditLog(id: string): unknown[] {
return readAudit(db, id);
}
/** Read the instance config with secret fields masked. */
getInstanceConfig(id: string): Record<string, unknown> {
const row = db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined;
@@ -0,0 +1,75 @@
/**
* Pure egress-policy helpers for the plugin child's network guard (#plugins, L1
* hardening). Kept dependency-free so the isolated child can import them without
* pulling in any privileged server module, and so they are unit-testable on
* their own (the guard that uses them lives in plugin-host-entry, which is
* excluded from coverage as a subprocess entry).
*/
/**
* Block outbound connections to loopback / private / link-local / ULA / carrier-
* grade-NAT / cloud-metadata / multicast / reserved destinations. This is the
* SSRF backstop: even a declared host that (re)resolves to one of these is
* refused, so a plugin can't pivot to trek.db's host, the 169.254.169.254
* metadata IP, the local docker network, or other internal services.
*/
export function isBlockedIp(ip: string): boolean {
const v4 = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (v4) {
const a = Number(v4[1]);
const b = Number(v4[2]);
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254) || // link-local, incl. 169.254.169.254 metadata
(a === 100 && b >= 64 && b <= 127) || // CGNAT 100.64/10
a >= 224 // multicast + reserved
);
}
const h = ip.toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
if (h === '::' || h === '::1' || h === '0:0:0:0:0:0:0:0' || h === '0:0:0:0:0:0:0:1') return true;
if (h.startsWith('fe80') || h.startsWith('fc') || h.startsWith('fd')) return true; // link-local + ULA
const mapped = h.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (mapped) return isBlockedIp(mapped[1]);
return false;
}
/** Build a declared-egress host matcher (exact host or `*.suffix` wildcard). */
export function makeHostAllow(egress: string[]): (host: string) => boolean {
const patterns = egress.map((h) => h.trim().toLowerCase()).filter(Boolean);
return (host: string) => {
const h = host.toLowerCase();
return patterns.some((p) => (p.startsWith('*.') ? h === p.slice(2) || h.endsWith(p.slice(1)) : h === p));
};
}
export interface ConnectTarget {
kind: 'local' | 'literal-ip' | 'hostname';
host: string;
}
/**
* Classify a `net.Socket.connect(...)` argument list into what we must check:
* a unix-socket/pipe (local, allowed), a literal IP (checked synchronously), or
* a hostname (allowlist + a DNS-resolving guard). Mirrors Node's connect
* overloads: connect(options[,cb]) | connect(port[,host][,cb]) | connect(path[,cb]).
*/
export function classifyConnect(args: unknown[], isIP: (s: string) => boolean): ConnectTarget {
const first = args[0];
if (first && typeof first === 'object') {
const o = first as { host?: string; path?: string };
if (o.path) return { kind: 'local', host: o.path };
const host = o.host ?? 'localhost';
return { kind: isIP(host) ? 'literal-ip' : 'hostname', host };
}
if (typeof first === 'string' && !/^\d+$/.test(first)) {
// a bare string that isn't a port number is an IPC path
return { kind: 'local', host: first };
}
// connect(port[, host][, cb])
const host = typeof args[1] === 'string' ? (args[1] as string) : 'localhost';
return { kind: isIP(host) ? 'literal-ip' : 'hostname', host };
}
@@ -12,8 +12,10 @@
import path from 'node:path';
import net from 'node:net';
import dns from 'node:dns';
import { createRequire } from 'node:module';
import { createPluginContext, type ChildTransport, type PluginContext, type PluginDefinition } from './plugin-sdk';
import { isBlockedIp, makeHostAllow, classifyConnect } from './egress-policy';
import type { Envelope, RpcError } from '../protocol/envelope';
const pluginId = process.argv[2] || process.env.TREK_PLUGIN_ID || 'unknown';
@@ -146,20 +148,23 @@ process.on('message', (raw: unknown) => {
* subdomain.
*
* Two layers, so a plugin can't just sidestep `fetch` with a raw socket:
* 1. `globalThis.fetch` is wrapped (undici path).
* 1. `globalThis.fetch` is wrapped (early hostname reject).
* 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.
* node:http / node:https / node:net / node:tls AND undici/fetch all funnel
* through. Here we additionally RESOLVE the destination and refuse any
* private/loopback/link-local/metadata/CGNAT/ULA address (SSRF + DNS-rebinding
* backstop), pinning the resolved IP into the connect so the name can't flip
* to an internal address between check and connect.
*
* 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.
* load a native addon to escape these wrappers. A kernel/network-namespace
* guarantee still belongs to the container runtime. Set
* TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on to permit private/internal targets (e.g. a
* self-hoster's sibling service) — default is the secure, block-private policy.
*/
function installEgressGuard(egress: string[]): void {
const patterns = egress.map((h) => h.trim().toLowerCase()).filter(Boolean);
const allowed = (hostname: string): boolean => {
const h = hostname.toLowerCase();
return patterns.some((p) => (p.startsWith('*.') ? h === p.slice(2) || h.endsWith(p.slice(1)) : h === p));
};
const allowed = makeHostAllow(egress);
const blockPrivate = (process.env.TREK_PLUGIN_ALLOW_PRIVATE_EGRESS ?? '').toLowerCase() !== 'on';
const realFetch = globalThis.fetch;
if (typeof realFetch === 'function') {
@@ -167,7 +172,7 @@ function installEgressGuard(egress: string[]): void {
const url = typeof input === 'string' ? input : (input as { url?: string })?.url ?? String(input);
let host: string;
try {
host = new URL(url).hostname;
host = new URL(url).hostname.replace(/^\[/, '').replace(/\]$/, '');
} catch {
return Promise.reject(new Error('egress: invalid url'));
}
@@ -176,25 +181,48 @@ function installEgressGuard(egress: string[]): void {
}) 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.
// A DNS lookup that refuses to resolve a name to a blocked address; injected
// into every hostname connect so the socket only ever reaches a vetted IP.
const guardedLookup = (
hostname: string,
options: unknown,
cb: (err: Error | null, address?: unknown, family?: number) => void,
): void => {
const opts = typeof options === 'function' ? {} : options;
if (typeof options === 'function') cb = options as typeof cb;
dns.lookup(hostname, opts as dns.LookupOptions, (err, address, family) => {
if (err) return cb(err, address as unknown, family);
const list = Array.isArray(address) ? address : [{ address: address as string }];
for (const a of list) {
if (blockPrivate && isBlockedIp((a as { address: string }).address)) {
return cb(new Error(`egress: ${hostname} resolves to a blocked address (${(a as { address: string }).address})`));
}
}
cb(null, address as unknown, family);
});
};
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 target = classifyConnect(args, (s) => net.isIP(s) !== 0);
if (target.kind === 'local') return realConnect.apply(this, args); // unix socket / pipe
if (!allowed(target.host)) {
throw new Error(`egress: ${target.host} is not in the plugin's declared hosts`);
}
if (target.kind === 'literal-ip') {
if (blockPrivate && isBlockedIp(target.host)) {
throw new Error(`egress: ${target.host} is a blocked address`);
}
return realConnect.apply(this, args);
}
// Hostname: inject the resolving guard. Preserve an existing lookup by
// wrapping the args' options object.
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 (host && !allowed(host)) {
throw new Error(`egress: ${host} is not in the plugin's declared hosts`);
}
return realConnect.apply(this, args);
const options = first && typeof first === 'object' ? { ...(first as object) } : { host: target.host, port: args[0] };
(options as { lookup?: unknown }).lookup = guardedLookup;
const rest = first && typeof first === 'object' ? args.slice(1) : args.slice(typeof args[1] === 'string' ? 2 : 1);
return realConnect.call(this, options, ...rest);
};
}
@@ -45,6 +45,7 @@ interface Supervised {
status: PluginStatus;
crashes: number[]; // crash timestamps (ms)
lastBeat: number;
lastRss: number; // last reported resident set size (bytes)
routes: PluginRouteInfo[];
jobs: string[];
pending: Map<string, Pending>; // host→child invokes awaiting a response
@@ -58,6 +59,7 @@ export interface SupervisorTuning {
crashLimit?: number;
backoffCapMs?: number;
killGraceMs?: number;
maxRssBytes?: number;
}
const DEFAULTS: Required<SupervisorTuning> = {
@@ -66,6 +68,10 @@ const DEFAULTS: Required<SupervisorTuning> = {
crashLimit: 5,
backoffCapMs: 30_000,
killGraceMs: 3000,
// Hard RSS ceiling — the real memory cap. --max-old-space-size only bounds the
// V8 heap; Buffers/ArrayBuffers/native allocations sail past it, so a plugin
// could OOM the box while staying "under" the heap limit. Overridable via env.
maxRssBytes: (Number(process.env.TREK_PLUGIN_MAX_RSS_MB) || 300) * 1024 * 1024,
};
export class PluginSupervisor {
@@ -94,6 +100,7 @@ export class PluginSupervisor {
status: 'starting',
crashes: [],
lastBeat: Date.now(),
lastRss: 0,
routes: [],
jobs: [],
pending: new Map(),
@@ -236,9 +243,12 @@ export class PluginSupervisor {
case 'hello':
sup.child?.send({ k: 'evt', topic: 'init', data: { config: sup.config, egress: sup.egress } } satisfies Envelope);
break;
case 'heartbeat':
case 'heartbeat': {
sup.lastBeat = Date.now();
const rss = (msg.data as { rss?: number })?.rss;
if (typeof rss === 'number') sup.lastRss = rss;
break;
}
case 'loaded': {
sup.lastBeat = Date.now();
const d = msg.data as { routes?: PluginRouteInfo[]; jobs?: string[] };
@@ -310,12 +320,23 @@ export class PluginSupervisor {
this.sweep.unref?.();
}
/** Kill any active plugin that has stopped sending heartbeats (drives the crash path). */
/**
* Kill any active plugin that has stopped sending heartbeats OR blown its RSS
* ceiling (drives the crash/backoff path, so a repeat offender auto-disables).
*/
reapStale(now = Date.now()): void {
for (const sup of this.running.values()) {
if (sup.status === 'active' && now - sup.lastBeat > this.tuning.heartbeatTimeoutMs) {
if (sup.status !== 'active') continue;
if (now - sup.lastBeat > this.tuning.heartbeatTimeoutMs) {
this.hooks.onLog?.(sup.id, 'warn', 'missed heartbeats; killing');
sup.child?.kill('SIGKILL');
} else if (sup.lastRss > this.tuning.maxRssBytes) {
this.hooks.onLog?.(
sup.id,
'warn',
`exceeded memory ceiling (${Math.round(sup.lastRss / 1048576)}MB > ${Math.round(this.tuning.maxRssBytes / 1048576)}MB); killing`,
);
sup.child?.kill('SIGKILL');
}
}
}
@@ -0,0 +1,71 @@
/**
* Egress-policy helpers for the plugin network guard (#plugins, L1 hardening):
* SSRF/private-IP blocking, declared-host allowlisting, and connect() arg
* classification.
*/
import { describe, it, expect } from 'vitest';
import net from 'node:net';
import { isBlockedIp, makeHostAllow, classifyConnect } from '../../../src/nest/plugins/runtime/egress-policy';
const isIP = (s: string) => net.isIP(s) !== 0;
describe('isBlockedIp', () => {
it.each([
'0.0.0.0', '10.1.2.3', '127.0.0.1', '172.16.0.1', '172.31.255.255',
'192.168.1.1', '169.254.169.254', '100.64.0.1', '224.0.0.1', '255.255.255.255',
'::1', '::', 'fe80::1', 'fc00::1', 'fd12:3456::1', '::ffff:127.0.0.1', '::ffff:10.0.0.1',
])('blocks %s', (ip) => {
expect(isBlockedIp(ip)).toBe(true);
});
it.each([
'8.8.8.8', '1.1.1.1', '140.82.121.3', '172.15.0.1', '172.32.0.1',
'100.63.0.1', '100.128.0.1', '2606:4700::1111', '::ffff:8.8.8.8',
])('allows public %s', (ip) => {
expect(isBlockedIp(ip)).toBe(false);
});
});
describe('makeHostAllow', () => {
it('matches exact hosts and *.suffix wildcards, case-insensitively', () => {
const allow = makeHostAllow(['api.example.com', '*.aviationstack.com']);
expect(allow('api.example.com')).toBe(true);
expect(allow('API.example.com')).toBe(true);
expect(allow('v2.aviationstack.com')).toBe(true);
expect(allow('aviationstack.com')).toBe(true); // apex matches *.suffix
expect(allow('evil.com')).toBe(false);
expect(allow('notapi.example.com')).toBe(false);
});
it('an empty egress list allows nothing', () => {
const allow = makeHostAllow([]);
expect(allow('anything.com')).toBe(false);
});
});
describe('classifyConnect', () => {
it('treats a unix-socket path (options.path) as local', () => {
expect(classifyConnect([{ path: '/tmp/x.sock' }], isIP)).toEqual({ kind: 'local', host: '/tmp/x.sock' });
});
it('treats a bare non-numeric string as a local IPC path', () => {
expect(classifyConnect(['/run/app.sock'], isIP).kind).toBe('local');
});
it('classifies an options object with a hostname', () => {
expect(classifyConnect([{ host: 'api.example.com', port: 443 }], isIP)).toEqual({ kind: 'hostname', host: 'api.example.com' });
});
it('classifies an options object with a literal IP', () => {
expect(classifyConnect([{ host: '10.0.0.5', port: 80 }], isIP)).toEqual({ kind: 'literal-ip', host: '10.0.0.5' });
});
it('classifies the (port, host) form', () => {
expect(classifyConnect([443, 'example.com'], isIP)).toEqual({ kind: 'hostname', host: 'example.com' });
expect(classifyConnect([80, '127.0.0.1'], isIP)).toEqual({ kind: 'literal-ip', host: '127.0.0.1' });
});
it('defaults a port-only connect to localhost', () => {
expect(classifyConnect([8080], isIP)).toEqual({ kind: 'hostname', host: 'localhost' });
});
});
@@ -0,0 +1,69 @@
/**
* Host-side capability audit log (#plugins, L1 hardening): resource projection,
* which methods are auditable, and the per-plugin hash chain.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
import { appendAudit, readAudit, auditResource, isAuditable } from '../../../src/nest/plugins/host/plugin-audit';
function makeDb() {
const db = new Database(':memory:');
db.exec(`CREATE TABLE plugin_capability_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT NOT NULL, acting_user_id INTEGER,
method TEXT NOT NULL, resource TEXT, code TEXT NOT NULL, ts TEXT NOT NULL,
prev_hash TEXT, hash TEXT NOT NULL);`);
return db;
}
describe('auditResource + isAuditable', () => {
it('projects a resource key from method + params (never raw params)', () => {
expect(auditResource('trips.getById', { tripId: 12 })).toBe('trip:12');
expect(auditResource('users.getById', { id: 3 })).toBe('user:3');
expect(auditResource('ws.broadcastToTrip', { tripId: 7 })).toBe('trip:7');
expect(auditResource('ws.broadcastToUser', { userId: 9 })).toBe('user:9');
expect(auditResource('db.query', { sql: 'x' })).toBeNull();
});
it('audits core-data + ws, not a plugin own-db call', () => {
expect(isAuditable('trips.getReservations')).toBe(true);
expect(isAuditable('users.getById')).toBe(true);
expect(isAuditable('ws.broadcastToTrip')).toBe(true);
expect(isAuditable('db.query')).toBe(false);
expect(isAuditable('db.migrate')).toBe(false);
});
});
describe('appendAudit hash chain', () => {
let db: ReturnType<typeof makeDb>;
beforeEach(() => { db = makeDb(); });
it('chains each entry off the previous hash (per plugin)', () => {
appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:2', code: 'ok' });
const rows = db.prepare('SELECT prev_hash, hash FROM plugin_capability_audit ORDER BY id').all() as Array<{ prev_hash: string | null; hash: string }>;
expect(rows).toHaveLength(2);
expect(rows[0].prev_hash).toBeNull();
expect(rows[1].prev_hash).toBe(rows[0].hash); // chain links
expect(rows[1].hash).not.toBe(rows[0].hash);
});
it('keeps separate chains per plugin', () => {
appendAudit(db, { pluginId: 'a', method: 'trips.getById', resource: 'trip:1', code: 'ok' });
appendAudit(db, { pluginId: 'b', method: 'trips.getById', resource: 'trip:1', code: 'ok' });
const a = db.prepare("SELECT prev_hash FROM plugin_capability_audit WHERE plugin_id='b'").get() as { prev_hash: string | null };
expect(a.prev_hash).toBeNull(); // b's first entry doesn't chain off a's
});
it('records denials too (code is the error code)', () => {
appendAudit(db, { pluginId: 'p', actingUserId: 99, method: 'trips.getById', resource: 'trip:1', code: 'RESOURCE_FORBIDDEN' });
const row = readAudit(db, 'p')[0] as { code: string; method: string };
expect(row.code).toBe('RESOURCE_FORBIDDEN');
});
it('readAudit returns newest first with the projected fields only', () => {
appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
const rows = readAudit(db, 'p') as Array<Record<string, unknown>>;
expect(rows[0]).toHaveProperty('method', 'trips.getById');
expect(rows[0]).not.toHaveProperty('prev_hash'); // internal chain fields not exposed
});
});
@@ -53,7 +53,8 @@ describe('PluginFrameController', () => {
expect(res.filePath).toContain(path.join('widget', 'client', 'index.html'));
const csp = res.headers['Content-Security-Policy'];
expect(csp).toContain("default-src 'none'");
expect(csp).toContain('sandbox allow-scripts allow-forms allow-popups');
expect(csp).toContain('sandbox allow-scripts allow-forms');
expect(csp).not.toContain('allow-popups');
expect(csp).not.toContain('allow-same-origin');
expect(csp).toContain('connect-src \'self\' https://api.weather.com');
expect(res.headers['X-Content-Type-Options']).toBe('nosniff');