mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +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:
@@ -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