mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user