Files
TREK/server/tests/unit/plugins/egress-policy.test.ts
T
Maurice 64136484ee fix(plugins): security hardening from the PR #1415 audit
Remediates the findings from the adversarial audit (threat model: malicious
plugin author + malicious artifact). Highlights:

Critical
- proxy: force nosniff + Content-Disposition: attachment on every proxied reply
  and drop location/content-disposition + non-2xx from the passthrough, so a
  plugin can't serve an HTML document at TREK's origin (sandbox-escape → account
  takeover) or an open redirect.

High
- db:own runs synchronously in the host: cap the plugin DB (max_page_count) and
  row-cap query() via iterate() so a recursive CTE / huge blob can't stall the
  event loop, OOM, or exhaust the shared volume.
- supervisor: measure child RSS host-side (/proc/<pid>/statm) instead of trusting
  the spoofable heartbeat; add an activation timeout so a stuck onLoad can't hang
  activate() or peg a core unreaped.
- safe-extract: enforce entry-count + cumulative-size limits INSIDE readZip
  before inflating (decompression-bomb OOM).
- re-consent: activate() never widens granted permissions without explicit
  consent (409 CONSENT_REQUIRED); the row toggle + "Update All" route through the
  consent dialog, which now queues instead of overwriting.

Medium/low
- egress: gate dgram hostnames through the IP-vetting resolver; block the
  low-level socket escape (process.binding) + lock the wrapped prototypes;
  canonicalize IPv6 in isBlockedIp (hex-mapped/compressed metadata); reject
  degenerate `*.` / whole-TLD / spaced outbound hosts in the manifest + CSP.
- ws:broadcast is membership-gated to the acting user's trips / own connections;
  users.getById is scoped to users the acting user can see (no enumeration).
- safe-fetch streams + aborts at the byte cap (chunked codeload OOM); isPrivateIp
  reuses the canonicalizing check.
- native-scan throws instead of silently passing past its entry cap.
- SDK: dev serves binary assets as raw buffers + handles EADDRINUSE; manifest
  validator gains the reserved-id + outbound-host checks; wikis corrected.

Tests updated for the new membership-gated behaviour + regression tests added
(IPv6 canonicalization, wildcard hardening, outbound-host validation, ws/user
scoping). 234 plugin tests + 24 SDK tests green.
2026-07-04 19:11:23 +02:00

119 lines
4.9 KiB
TypeScript

/**
* 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, dgramSendTarget, dgramConnectTarget,
} 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',
// non-canonical IPv6 spellings must be blocked too (canonicalization)
'0::1', '::ffff:a9fe:a9fe', '::ffff:169.254.169.254', '0:0:0:0:0:0:0:1', 'fD00::1', '::ffff:7f00: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);
});
it('rejects degenerate wildcards that would be allow-all', () => {
// `*.` matched any trailing-dot host; `*.com`/`*` are whole-TLD/allow-all.
expect(makeHostAllow(['*.'])('evil.com')).toBe(false);
expect(makeHostAllow(['*.'])('evil.com.')).toBe(false);
expect(makeHostAllow(['*.com'])('evil.com')).toBe(false);
expect(makeHostAllow(['*'])('evil.com')).toBe(false);
});
it('normalizes a trailing dot so an FQDN host cannot dodge an exact match', () => {
const allow = makeHostAllow(['api.example.com']);
expect(allow('api.example.com.')).toBe(true); // trailing-dot FQDN still matches
// and the FQDN trick can't turn a real wildcard into allow-all
expect(makeHostAllow(['*.example.com'])('sink.attacker.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' });
});
});
describe('dgramSendTarget', () => {
const msg = Buffer.from('x');
it('reads the address from send(msg, port, address)', () => {
expect(dgramSendTarget([msg, 53, 'attacker.com'])).toBe('attacker.com');
});
it('reads the address from the send(msg, offset, length, port, address) form', () => {
expect(dgramSendTarget([msg, 0, 1, 53, 'attacker.com'])).toBe('attacker.com');
});
it('is null when no explicit address is given (connected / localhost default)', () => {
expect(dgramSendTarget([msg, 53])).toBeNull();
expect(dgramSendTarget([msg])).toBeNull();
expect(dgramSendTarget([msg, 0, 1, 53])).toBeNull();
});
it('ignores a trailing callback in the address slot', () => {
expect(dgramSendTarget([msg, 53, () => {}])).toBeNull();
});
});
describe('dgramConnectTarget', () => {
it('reads the address from connect(port, address)', () => {
expect(dgramConnectTarget([53, 'attacker.com'])).toBe('attacker.com');
});
it('is null for connect(port) or connect(port, cb)', () => {
expect(dgramConnectTarget([53])).toBeNull();
expect(dgramConnectTarget([53, () => {}])).toBeNull();
});
});