mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
57c676ac37
The egress guard only wrapped fetch and net.Socket.connect, so TCP and HTTP were contained but two channels stayed wide open: a plugin could send data out over UDP (node:dgram) or tunnel it inside DNS queries (dns.resolveTxt & friends) to any host it never declared. Neither goes through net.Socket.connect, so the allowlist never saw them. Wrap both now against the same declared-host allowlist: - dgram send/connect: the explicit destination is allowlisted and private-IP-checked like a TCP connect (a null address keeps the connected/localhost default, which the connect wrapper already vetted). - the dns resolver family (module fns, dns.promises, Resolver.prototype): a forward lookup for an undeclared name is refused, which kills DNS tunnelling even when no socket is ever opened. A plugin with no declared egress now really has no way out.
102 lines
4.0 KiB
TypeScript
102 lines
4.0 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',
|
|
])('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' });
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|