openvibers/openvibe.tools Commits

SSRF: the network tools refuse internal targets. apps/_shared/egress.js (from OpenVibe.Events) checks every resolved address after DNS (loopback, RFC 1918, link-local and 169.254.169.254, CGNAT, 0.0.0.0/8, multicast, documentation/benchmark, ULA/site-local v6, v4-mapped/NAT64/6to4/Teredo wrapping those) and dials only the checked address (IP literal + SNI for sockets, a pinned lookup for HTTP, so no rebinding)

OP OpenVibers committed 23 Sept 2026 · f9a1441872 · 7 files, +683 −144

/api/net/port, ping, ssl, headers, redirects, lookup, the DNS tool's custom server and
/api/dev/opengraph all go through it; a refusal is 403 { ok:false, error, code:
tools.net.target_not_public }. localhost, *.localhost, *.local, *.internal, *.home.arpa and
single-label names are refused before DNS. Headers never follows redirects; Redirects re-checks
every hop and stops at a refused one (chain entry refused:true, stopped); Open Graph and Lookup
follow at most 5 hops, each re-checked; Open Graph reads at most 2 MB. Tests: the guard with a
mock resolver (and a rebinding resolver that answers differently the second time), and every
route against localhost, 127.0.0.1, [::1], 10/8, 192.168/16, 169.254.169.254, a name resolving
to 127.0.0.1 and a redirect to loopback, plus public targets still answered.

Changes

ddiff --git a/README.md b/README.md
iindex d0f1507..b2f5120 100644
---- a/README.md
++++ b/README.md
@@@ -23,7 +23,9 @@ apps/
├── text # Text.OpenVibe + Logo.OpenVibe — text generators & logo makers
└── docs # Docs.OpenVibe — PDF & document tools
apps/_shared # code the apps require by relative path: host roles, internal auth, the job runtime (jobs/),
-- # visit analytics within ADR-021 (analytics/)
++ # visit analytics within ADR-021 (analytics/), the SSRF guard (egress.js) that every tool
++ # reaching a visitor-chosen host or URL goes through (public addresses only, checked after
++ # DNS and dialled as checked, redirect hops re-checked)
scripts/analytics-prune.js # operator CLI: raw analytics retention + one-time scrub (dry run by default)
(openvibe-shared is a pinned OpenVibe.Shared release in each app's package.json; no vendor/ copy)
```
ddiff --git a/apps/_shared/egress.js b/apps/_shared/egress.js
nnew file mode 100644
iindex 0000000..8dc47e9
---- /dev/null
++++ b/apps/_shared/egress.js
@@@ -0,0 +1,279 @@
++'use strict';
++/**
++ * SSRF guard for every tool that connects to a host or URL a visitor chose (Net.OpenVibe port
++ * check, ping, SSL, headers, redirects, lookup; Dev.OpenVibe Open Graph; the DNS tool's custom
++ * server). Adapted from OpenVibe.Events server/egress.js.
++ *
++ * - every address the hostname resolves to must be public: no loopback, RFC 1918, link-local
++ * (cloud metadata 169.254.169.254 included), CGNAT, 0.0.0.0/8, multicast, documentation,
++ * benchmarking, reserved, unique-local or site-local IPv6, and no IPv6 form that wraps one of
++ * those (v4-mapped, NAT64, 6to4, Teredo)
++ * - the check runs AFTER DNS resolution, and the connection goes to the checked address: raw
++ * sockets connect to the IP literal (SNI set to the hostname), HTTP requests get a `lookup`
++ * that only ever answers with the addresses already checked, so a second DNS answer (rebinding)
++ * is never consulted
++ * - redirects are not followed by `request`; `follow` re-runs the whole check on every hop
++ * - localhost, *.localhost, *.local, *.internal and single-label names are refused before DNS
++ *
++ * createEgress({ lookup, … }) takes the resolver and transports as options so tests run without
++ * a network. A refusal throws TargetRefused (status 403, code tools.net.target_not_public).
++ */
++const dns = require('dns');
++const net = require('net');
++const tls = require('tls');
++const http = require('http');
++const https = require('https');
++
++const blocked = new net.BlockList();
++for (const [addr, prefix] of [
++ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
++ ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
++ ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
++]) blocked.addSubnet(addr, prefix, 'ipv4');
++for (const [addr, prefix] of [
++ ['::', 128], ['::1', 128], ['::', 96], ['100::', 64], ['2001::', 23], ['2001:db8::', 32], ['fc00::', 7], ['fe80::', 10],
++ ['fec0::', 10], ['ff00::', 8], ['3fff::', 20], ['5f00::', 16],
++]) blocked.addSubnet(addr, prefix, 'ipv6');
++
++function expandV6(ip) {
++ let s = String(ip).toLowerCase();
++ const pct = s.indexOf('%');
++ if (pct >= 0) s = s.slice(0, pct);
++ if (!net.isIPv6(s)) return null;
++ // Trailing dotted quad -> two words.
++ const dq = /(\d+\.\d+\.\d+\.\d+)$/.exec(s);
++ if (dq) {
++ const p = dq[1].split('.').map(Number);
++ s = s.slice(0, -dq[1].length) + `${((p[0] << 8) | p[1]).toString(16)}:${((p[2] << 8) | p[3]).toString(16)}`;
++ }
++ const [head, tail] = s.split('::');
++ const h = head ? head.split(':') : [];
++ const t = tail !== undefined ? (tail ? tail.split(':') : []) : null;
++ const words = t === null ? h : [...h, ...Array(8 - h.length - t.length).fill('0'), ...t];
++ if (words.length !== 8) return null;
++ return words.map(w => parseInt(w || '0', 16));
++}
++
++/** The IPv4 address an IPv6 address carries (v4-mapped, NAT64, 6to4, Teredo), or null. */
++function embeddedV4(ip) {
++ const words = expandV6(ip);
++ if (!words) return null;
++ const v4 = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
++ if (words.slice(0, 5).every(w => w === 0) && words[5] === 0xffff) return v4(words[6], words[7]); // ::ffff:a.b.c.d
++ if (words[0] === 0x64 && words[1] === 0xff9b) return v4(words[6], words[7]); // 64:ff9b::/96 (and /48)
++ if (words[0] === 0x2002) return v4(words[1], words[2]); // 6to4
++ if (words[0] === 0x2001 && words[1] === 0) return v4(words[6] ^ 0xffff, words[7] ^ 0xffff); // Teredo client
++ return null;
++}
++
++/** True only for a globally routable unicast address. */
++function isPublicAddress(ip) {
++ const s = String(ip || '');
++ const family = net.isIP(s);
++ if (family === 4) return !blocked.check(s, 'ipv4');
++ if (family === 6) {
++ const bare = s.split('%')[0];
++ if (blocked.check(bare, 'ipv6')) return false;
++ const v4 = embeddedV4(bare);
++ if (v4 !== null) return !blocked.check(v4, 'ipv4');
++ return true;
++ }
++ return false;
++}
++
++class TargetRefused extends Error {
++ constructor(message) {
++ super(message);
++ this.name = 'TargetRefused';
++ this.code = 'tools.net.target_not_public';
++ this.status = 403;
++ }
++}
++
++/** Lower-case, drop [brackets] and trailing dots. */
++function normalizeHost(host) {
++ let h = String(host || '').trim().toLowerCase();
++ if (h.startsWith('[') && h.endsWith(']')) h = h.slice(1, -1);
++ return h.replace(/\.+$/, '');
++}
++
++/** Names refused before any DNS: they can only mean this machine or a private network. */
++function isInternalName(host) {
++ return host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')
++ || host.endsWith('.home.arpa') || !host.includes('.');
++}
++
++const refusedMessage = (what) => `${what} is not a public internet address; the network tools only reach public hosts`;
++
++function createEgress({
++ lookup = dns.lookup, isAllowed = isPublicAddress,
++ httpRequest = http.request, httpsRequest = https.request, tcpConnect = net.connect, tlsConnect = tls.connect,
++} = {}) {
++
++ /**
++ * Resolve `host` and check every address. → { host, literal, address, family, addresses }.
++ * Throws TargetRefused, or an Error with status 400 when the name does not resolve.
++ * `prefer: 4` picks an IPv4 address first when there is one (every address is still checked).
++ */
++ function resolve(rawHost, { prefer = 0 } = {}) {
++ const host = normalizeHost(rawHost);
++ if (!host) return Promise.reject(Object.assign(new Error('Please provide a host'), { status: 400 }));
++ const family = net.isIP(host);
++ if (family) {
++ if (!isAllowed(host)) return Promise.reject(new TargetRefused(refusedMessage(host)));
++ return Promise.resolve({ host, literal: true, address: host, family, addresses: [{ address: host, family }] });
++ }
++ if (isInternalName(host)) return Promise.reject(new TargetRefused(refusedMessage(host)));
++ return new Promise((ok, fail) => {
++ lookup(host, { all: true, verbatim: true }, (err, addresses) => {
++ if (err) return fail(Object.assign(new Error(`Cannot resolve ${host}`), { status: 400, cause: err }));
++ const list = (Array.isArray(addresses) ? addresses : [{ address: addresses, family: net.isIP(addresses) }])
++ .map(a => ({ address: a.address, family: a.family || net.isIP(a.address) }));
++ if (!list.length) return fail(Object.assign(new Error(`Cannot resolve ${host}`), { status: 400 }));
++ if (list.some(a => !isAllowed(a.address))) return fail(new TargetRefused(refusedMessage(`${host} resolves to an address that`)));
++ const pick = (prefer && list.find(a => a.family === prefer)) || list[0];
++ ok({ host, literal: false, address: pick.address, family: pick.family, addresses: list });
++ });
++ });
++ }
++
++ /** A socket `lookup` that only answers with addresses that were already checked. */
++ function pinnedLookup(addresses) {
++ return (hostname, opts, cb) => {
++ if (typeof opts === 'function') { cb = opts; opts = {}; }
++ const fam = (opts && opts.family) || 0;
++ const list = addresses.filter(a => !fam || a.family === fam);
++ if (!list.length) return cb(Object.assign(new Error(`${hostname} has no IPv${fam} address`), { code: 'ENOTFOUND' }));
++ if (opts && opts.all) return cb(null, list.map(a => ({ address: a.address, family: a.family })));
++ return cb(null, list[0].address, list[0].family);
++ };
++ }
++
++ function assertChecked(address) {
++ if (!net.isIP(address) || !isAllowed(address)) throw new TargetRefused(refusedMessage(address));
++ }
++
++ /**
++ * TCP connect to a checked address. → Promise<{ status: 'open'|'closed'|'filtered', ms, error? }>.
++ */
++ function tcpProbe(address, port, { timeoutMs = 5000 } = {}) {
++ assertChecked(address);
++ return new Promise((resolveProbe) => {
++ const start = Date.now();
++ let settled = false;
++ const done = (r, socket) => { if (settled) return; settled = true; socket.destroy(); resolveProbe(r); };
++ const socket = tcpConnect({ host: address, port });
++ socket.setTimeout(timeoutMs);
++ socket.on('connect', () => done({ status: 'open', ms: Date.now() - start }, socket));
++ socket.on('timeout', () => done({ status: 'filtered', ms: timeoutMs, error: 'timeout' }, socket));
++ socket.on('error', (err) => done({ status: err.code === 'ECONNREFUSED' ? 'closed' : 'filtered', ms: Date.now() - start, error: err.code || err.message }, socket));
++ });
++ }
++
++ /**
++ * TLS handshake with a checked address, SNI = the hostname. → Promise<{ cert, protocol, cipher }>.
++ * `target` is the result of resolve().
++ */
++ function tlsHandshake(target, port, { timeoutMs = 10000, detailed = true } = {}) {
++ assertChecked(target.address);
++ return new Promise((ok, fail) => {
++ const socket = tlsConnect({
++ host: target.address, port,
++ servername: target.literal ? undefined : target.host,
++ // Verify against the name the person typed, not the IP we dialled.
++ checkServerIdentity: (_h, cert) => tls.checkServerIdentity(target.host, cert),
++ }, () => {
++ const cert = socket.getPeerCertificate(detailed);
++ const protocol = socket.getProtocol();
++ const cipher = socket.getCipher();
++ socket.end();
++ ok({ cert, protocol, cipher });
++ });
++ socket.on('error', fail);
++ socket.setTimeout(timeoutMs, () => socket.destroy(new Error('Timeout')));
++ });
++ }
++
++ /** Parse and vet a URL (syntax only). → URL. Throws 400 / TargetRefused. */
++ function parseUrl(raw) {
++ let url;
++ try { url = new URL(raw); } catch { throw Object.assign(new Error('Invalid URL'), { status: 400 }); }
++ if (url.protocol !== 'http:' && url.protocol !== 'https:') throw Object.assign(new Error('Only http and https URLs are supported'), { status: 400 });
++ if (url.username || url.password) throw Object.assign(new Error('Credentials in the URL are not supported'), { status: 400 });
++ return url;
++ }
++
++ /**
++ * One HTTP(S) request to a user-chosen URL; redirects are NOT followed.
++ * → Promise<{ url, status, statusText, headers, body, truncated }>. body is a Buffer ('' for HEAD
++ * or when maxBytes is 0).
++ */
++ async function request(rawUrl, { method = 'GET', headers = {}, timeoutMs = 12000, maxBytes = 0 } = {}) {
++ const url = parseUrl(rawUrl);
++ const target = await resolve(url.hostname);
++ const secure = url.protocol === 'https:';
++ return new Promise((ok, fail) => {
++ let settled = false;
++ let deadline = null;
++ const done = (fn, v) => { if (!settled) { settled = true; clearTimeout(deadline); fn(v); } };
++ const req = (secure ? httpsRequest : httpRequest)({
++ hostname: target.literal ? target.address : target.host,
++ port: url.port || (secure ? 443 : 80),
++ path: `${url.pathname}${url.search}`,
++ method,
++ headers,
++ agent: false,
++ // Only the checked addresses: no second DNS answer is ever used.
++ lookup: target.literal ? undefined : pinnedLookup(target.addresses),
++ servername: secure && !target.literal ? target.host : undefined,
++ timeout: timeoutMs,
++ }, (res) => {
++ const flat = {};
++ for (const [k, v] of Object.entries(res.headers || {})) flat[k] = Array.isArray(v) ? v.join(', ') : v;
++ const out = { url: url.href, status: res.statusCode, statusText: res.statusMessage || '', headers: flat, body: Buffer.alloc(0), truncated: false };
++ if (method === 'HEAD' || !maxBytes) { res.destroy(); return done(ok, out); }
++ const chunks = [];
++ let size = 0;
++ const finish = (truncated) => {
++ if (settled) return;
++ out.truncated = truncated;
++ out.body = truncated ? Buffer.concat(chunks).subarray(0, maxBytes) : Buffer.concat(chunks);
++ done(ok, out);
++ };
++ res.on('data', (c) => {
++ chunks.push(c); size += c.length;
++ if (size > maxBytes) { finish(true); res.destroy(); }
++ });
++ res.on('end', () => finish(false));
++ res.on('error', (err) => done(fail, err));
++ res.on('close', () => finish(false));
++ });
++ const timeout = () => req.destroy(Object.assign(new Error('Timeout'), { name: 'TimeoutError' }));
++ deadline = setTimeout(timeout, timeoutMs);
++ req.on('timeout', timeout);
++ req.on('error', (err) => done(fail, err));
++ req.end();
++ });
++ }
++
++ /**
++ * request() that follows up to `maxRedirects` redirects, re-checking every hop.
++ * → the final response plus `chain` (every hop's url and status). A hop to a non-public address
++ * throws TargetRefused before any connection is made to it.
++ */
++ async function follow(rawUrl, { maxRedirects = 5, ...opts } = {}) {
++ let current = rawUrl;
++ const chain = [];
++ for (let i = 0; ; i++) {
++ const r = await request(current, opts);
++ chain.push({ url: r.url, status: r.status });
++ const loc = r.headers.location;
++ if (!(r.status >= 300 && r.status < 400 && loc) || i >= maxRedirects) return { ...r, chain };
++ current = new URL(loc, r.url).href;
++ }
++ }
++
++ return { resolve, pinnedLookup, tcpProbe, tlsHandshake, parseUrl, request, follow, isAllowed };
++}
++
++module.exports = { createEgress, isPublicAddress, embeddedV4, normalizeHost, TargetRefused };
ddiff --git a/apps/_shared/test/egress.test.js b/apps/_shared/test/egress.test.js
nnew file mode 100644
iindex 0000000..71ad275
---- /dev/null
++++ b/apps/_shared/test/egress.test.js
@@@ -0,0 +1,129 @@
++'use strict';
++// The shared SSRF guard (apps/_shared/egress.js): address classes, checks after DNS, pinned
++// connections (no rebinding window), redirect hops re-checked. No real network: resolvers are mocks
++// and the only server is an in-process one on loopback.
++const assert = require('assert');
++const http = require('http');
++const { createEgress, isPublicAddress, embeddedV4, TargetRefused } = require('../egress');
++
++(async () => {
++ // ── Address classes ──
++ for (const ip of ['127.0.0.1', '127.1.2.3', '10.0.0.1', '172.16.5.4', '172.31.255.255', '192.168.1.1', '169.254.169.254',
++ '100.64.0.1', '0.0.0.0', '0.1.2.3', '224.0.0.1', '239.255.255.250', '255.255.255.255', '192.0.2.10', '198.51.100.1',
++ '203.0.113.9', '198.18.0.1', '192.0.0.8', '240.0.0.1',
++ '::1', '::', 'fd00::1', 'fc00::1', 'fe80::1', 'fe80::1%eth0', 'fec0::1', 'ff02::1', '2001:db8::1',
++ '::ffff:127.0.0.1', '::ffff:7f00:1', '::ffff:10.0.0.1', '::ffff:169.254.169.254', '::127.0.0.1',
++ '64:ff9b::a00:1', '2002:c0a8:101::1', 'not-an-ip', '']) {
++ assert.equal(isPublicAddress(ip), false, `${ip} is not public`);
++ }
++ for (const ip of ['93.184.216.34', '1.1.1.1', '8.8.8.8', '172.32.0.1', '100.128.0.1', '2606:4700:4700::1111', '::ffff:93.184.216.34', '2a00:1450:4001::1']) {
++ assert.equal(isPublicAddress(ip), true, `${ip} is public`);
++ }
++ assert.equal(embeddedV4('::ffff:7f00:1'), '127.0.0.1');
++ assert.equal(embeddedV4('2002:c0a8:101::1'), '192.168.1.1');
++ console.log('address classes: ok');
++
++ // ── resolve(): checks after DNS, every answer, names refused before DNS ──
++ const table = {
++ 'public.example': ['93.184.216.34', '2606:4700::6810:1'],
++ 'rebind.example': ['127.0.0.1'],
++ 'meta.example': ['169.254.169.254'],
++ 'mixed.example': ['93.184.216.34', '10.0.0.7'],
++ 'mapped.example': ['::ffff:127.0.0.1'],
++ };
++ let lookups = [];
++ const lookup = (host, opts, cb) => {
++ lookups.push(host);
++ const a = table[host];
++ if (!a) return cb(Object.assign(new Error('not found'), { code: 'ENOTFOUND' }));
++ cb(null, a.map(address => ({ address, family: address.includes(':') ? 6 : 4 })));
++ };
++ const g = createEgress({ lookup });
++ const refused = async (p, what) => {
++ await assert.rejects(p, (err) => err instanceof TargetRefused && err.status === 403 && err.code === 'tools.net.target_not_public', what);
++ };
++ for (const h of ['localhost', 'LOCALHOST.', 'app.localhost', 'printer.local', 'metadata.google.internal', 'intranet', '2130706433']) {
++ lookups = [];
++ await refused(g.resolve(h), h);
++ assert.deepEqual(lookups, [], `${h} is refused before DNS`);
++ }
++ for (const h of ['127.0.0.1', '[::1]', '10.1.2.3', '192.168.0.10', '169.254.169.254', '0.0.0.0', '100.64.1.1', '[::ffff:127.0.0.1]', '[fd12::1]']) {
++ await refused(g.resolve(h), h);
++ }
++ for (const h of ['rebind.example', 'meta.example', 'mixed.example', 'mapped.example']) await refused(g.resolve(h), h);
++ await assert.rejects(g.resolve('nxdomain.example'), (err) => err.status === 400 && /Cannot resolve/.test(err.message));
++ const pub = await g.resolve('Public.Example.', { prefer: 4 });
++ assert.deepEqual([pub.host, pub.address, pub.family, pub.literal, pub.addresses.length], ['public.example', '93.184.216.34', 4, false, 2]);
++ assert.equal((await g.resolve('[2606:4700:4700::1111]')).address, '2606:4700:4700::1111');
++ console.log('resolve: ok');
++
++ // ── Raw sockets only dial checked addresses ──
++ const dialled = [];
++ const { EventEmitter } = require('events');
++ const fakeSocket = (ok) => { const s = new EventEmitter(); s.setTimeout = () => {}; s.destroy = () => {}; process.nextTick(() => ok ? s.emit('connect') : s.emit('error', Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }))); return s; };
++ const g2 = createEgress({ lookup, tcpConnect: ({ host, port }) => { dialled.push(`${host}:${port}`); return fakeSocket(port === 443); } });
++ assert.throws(() => g2.tcpProbe('127.0.0.1', 22), TargetRefused, 'tcpProbe re-checks the address it is handed');
++ assert.throws(() => g2.tcpProbe('public.example', 22), TargetRefused, 'tcpProbe takes addresses, not names');
++ assert.equal((await g2.tcpProbe('93.184.216.34', 443)).status, 'open');
++ assert.equal((await g2.tcpProbe('93.184.216.34', 22)).status, 'closed');
++ assert.deepEqual(dialled, ['93.184.216.34:443', '93.184.216.34:22']);
++ console.log('tcp probe: ok');
++
++ // ── HTTP: the socket uses the checked address (one DNS answer, never a second) ──
++ const seen = [];
++ const srv = http.createServer((req, res) => {
++ seen.push({ host: req.headers.host, url: req.url });
++ if (req.url === '/to-private') { res.writeHead(302, { Location: 'http://10.0.0.5/admin' }); return res.end(); }
++ if (req.url === '/to-meta') { res.writeHead(301, { Location: 'http://meta.example/latest/meta-data/' }); return res.end(); }
++ if (req.url === '/hop') { res.writeHead(302, { Location: '/final' }); return res.end(); }
++ if (req.url === '/big') { res.writeHead(200, { 'Content-Type': 'text/plain' }); return res.end('x'.repeat(100000)); }
++ res.writeHead(200, { 'Content-Type': 'text/html', 'Set-Cookie': ['a=1', 'b=2'] });
++ res.end('<title>hello</title>');
++ });
++ await new Promise(r => srv.listen(0, '127.0.0.1', r));
++ const port = srv.address().port;
++ // This test pretends 127.0.0.1 is public so the in-process server stands in for a public host.
++ let dnsCalls = 0;
++ const flipLookup = (host, opts, cb) => {
++ dnsCalls++;
++ if (host === 'site.example') return cb(null, [{ address: dnsCalls === 1 ? '127.0.0.1' : '10.9.9.9', family: 4 }]);
++ return lookup(host, opts, cb);
++ };
++ const dials = [];
++ const httpRequest = (opts, cb) => { dials.push(opts.hostname); return http.request(opts, cb); };
++ const g3 = createEgress({ lookup: flipLookup, isAllowed: (a) => a === '127.0.0.1' || isPublicAddress(a), httpRequest });
++ const r1 = await g3.request(`http://site.example:${port}/page`, { maxBytes: 1000 });
++ assert.equal(r1.status, 200);
++ assert.equal(r1.body.toString(), '<title>hello</title>');
++ assert.equal(r1.headers['set-cookie'], 'a=1, b=2');
++ assert.equal(seen[0].host, `site.example:${port}`, 'Host header is the name the person typed');
++ assert.equal(dnsCalls, 1, 'one DNS answer: the socket used the pinned address, not a second (rebound) lookup');
++ const big = await g3.request(`http://127.0.0.1:${port}/big`, { maxBytes: 1000 });
++ assert.equal(big.body.length, 1000); assert.equal(big.truncated, true);
++ const head = await g3.request(`http://127.0.0.1:${port}/`, { method: 'HEAD' });
++ assert.equal(head.status, 200); assert.equal(head.body.length, 0);
++
++ // request() never follows; follow() re-checks each hop and refuses before dialling.
++ const one = await g3.request(`http://127.0.0.1:${port}/to-private`);
++ assert.equal(one.status, 302);
++ dials.length = 0; seen.length = 0;
++ await refused(g3.follow(`http://127.0.0.1:${port}/to-private`), 'redirect to 10.0.0.5');
++ await refused(g3.follow(`http://127.0.0.1:${port}/to-meta`), 'redirect to a name resolving to 169.254.169.254');
++ assert.deepEqual(dials, ['127.0.0.1', '127.0.0.1'], 'the refused hops were never dialled');
++ const followed = await g3.follow(`http://127.0.0.1:${port}/hop`, { maxBytes: 100 });
++ assert.equal(followed.status, 200);
++ assert.deepEqual(followed.chain.map(c => c.status), [302, 200]);
++
++ // A real rebinding resolver against the default guard: loopback is refused.
++ const g4 = createEgress({ lookup: (h, o, cb) => cb(null, [{ address: '127.0.0.1', family: 4 }]) });
++ await refused(g4.request(`http://anything.example:${port}/`), 'hostname resolving to 127.0.0.1');
++ for (const u of [`http://localhost:${port}/`, `http://127.0.0.1:${port}/`, 'http://[::1]:3000/', 'http://169.254.169.254/latest/meta-data/', 'http://10.0.0.1/', 'http://192.168.1.1/', 'http://2130706433/', 'http://0x7f000001/', 'http://[::ffff:7f00:1]/']) {
++ await refused(g4.request(u), u);
++ }
++ await assert.rejects(g4.request('ftp://example.com/'), (e) => e.status === 400);
++ await assert.rejects(g4.request('http://user:[email protected]/'), (e) => e.status === 400);
++ srv.close();
++ console.log('http request + follow: ok');
++
++ console.log('egress (SSRF guard): all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/apps/gateway/public/net.html b/apps/gateway/public/net.html
iindex 95ae104..903d154 100644
---- a/apps/gateway/public/net.html
++++ b/apps/gateway/public/net.html
@@@ -1171,10 +1171,10 @@
${chain.map((h, i) => `<tr>
<td class="val">${i + 1}</td>
<td class="val" style="max-width:400px;overflow:hidden;text-overflow:ellipsis">${esc(h.url)}</td>
-- <td><span class="badge ${h.status < 400 ? (h.status >= 300 ? 'badge-yellow' : 'badge-green') : 'badge-red'}">${h.status}</span></td>
++ <td>${h.status == null ? `<span class="badge badge-red" title="${esc(h.error || '')}">${h.refused ? 'refused' : 'error'}</span>` : `<span class="badge ${h.status < 400 ? (h.status >= 300 ? 'badge-yellow' : 'badge-green') : 'badge-red'}">${h.status}</span>`}</td>
<td class="val">${h.ms}ms</td>
</tr>`).join('')}
-- </table></div>`;
++ </table>${data.stopped ? `<p style="color:var(--text-muted);font-size:13px">Stopped: ${esc(data.stopped)}</p>` : ''}</div>`;
el.innerHTML = html;
}
ddiff --git a/apps/gateway/server/dev/routes.js b/apps/gateway/server/dev/routes.js
iindex 80d6bf6..60ffe97 100644
---- a/apps/gateway/server/dev/routes.js
++++ b/apps/gateway/server/dev/routes.js
@@@ -9,6 +9,7 @@
const { Router } = require('express');
const crypto = require('crypto');
const { DEV_TOOLS } = require('./config');
++const { createEgress, TargetRefused } = require('../../../_shared/egress');
// ── In-memory webhook bin storage ────────────────────────────
const webhookBins = new Map();
@@@ -22,21 +23,10 @@ function cleanupBins() {
if (now - bin.created > WEBHOOK_BIN_TTL) webhookBins.delete(id);
}
}
--setInterval(cleanupBins, 5 * 60 * 1000);
++setInterval(cleanupBins, 5 * 60 * 1000).unref();
// ── Helpers ──────────────────────────────────────────────────
--async function timedFetch(url, opts = {}, timeoutMs = 10000) {
-- const ac = new AbortController();
-- const timer = setTimeout(() => ac.abort(), timeoutMs);
-- try {
-- const res = await fetch(url, { ...opts, signal: ac.signal, redirect: 'follow' });
-- clearTimeout(timer);
-- return res;
-- } catch (err) {
-- clearTimeout(timer);
-- throw err;
-- }
--}
++const OG_MAX_BYTES = 2 * 1024 * 1024; // Open Graph tags live in <head>; never buffer more than this
function extractOGTags(html) {
const tags = {};
@@@ -60,8 +50,10 @@ function extractOGTags(html) {
}
// ═════════════════════════════════════════════════════════════
--module.exports = function createDevRoutes(db, requireAuth) {
++// `opts.egress` is for tests: the shared SSRF guard with a mock resolver and transports.
++module.exports = function createDevRoutes(db, requireAuth, opts = {}) {
const router = Router();
++ const egress = opts.egress || createEgress();
// Optional auth — attaches req.user if token present, but doesn't block.
// Verifies the shared ov_token OFFLINE against the Network public key.
@@@ -102,13 +94,19 @@ module.exports = function createDevRoutes(db, requireAuth) {
}
try {
-- const response = await timedFetch(targetUrl, {
++ // Public addresses only, checked after DNS and dialled as checked; each redirect hop is
++ // checked again (shared SSRF guard).
++ const response = await egress.follow(targetUrl, {
++ method: 'GET',
headers: {
'User-Agent': 'OpenVibeOpenGraph/1.0 (https://opengraph.openvibe.tools)',
'Accept': 'text/html,application/xhtml+xml',
},
++ timeoutMs: 10000,
++ maxBytes: OG_MAX_BYTES,
++ maxRedirects: 5,
});
-- const html = await response.text();
++ const html = response.body.toString('utf8');
const tags = extractOGTags(html);
// Build structured result
@@@ -141,6 +139,8 @@ module.exports = function createDevRoutes(db, requireAuth) {
res.json(result);
} catch (err) {
++ if (err instanceof TargetRefused) return res.status(403).json({ error: err.message, code: err.code });
++ if (err && err.status === 400) return res.status(400).json({ error: err.message });
res.status(502).json({ error: `Failed to fetch: ${err.message}` });
}
});
ddiff --git a/apps/gateway/server/net/routes.js b/apps/gateway/server/net/routes.js
iindex b00f8db..de8568b 100644
---- a/apps/gateway/server/net/routes.js
++++ b/apps/gateway/server/net/routes.js
@@@ -11,15 +11,18 @@
const express = require('express');
const dns = require('dns').promises;
const { URL } = require('url');
--const tls = require('tls');
const net = require('net');
--const https = require('https');
--const http = require('http');
const { getNetConfig, NET_TOOLS } = require('./config');
++const { createEgress, TargetRefused } = require('../../../_shared/egress');
--const router = express.Router();
--
--module.exports = function createNetRoutes(db, requireAuth) {
++/**
++ * Every tool here that connects to the target (ssl, headers, redirects, port, ping, lookup, and a
++ * custom DNS server) goes through the shared SSRF guard: only public addresses, checked after DNS
++ * and dialled as checked. `opts.egress` is for tests (a guard with a mock resolver and transports).
++ */
++module.exports = function createNetRoutes(db, requireAuth, opts = {}) {
++ const router = express.Router();
++ const egress = opts.egress || createEgress();
// ── Helpers ──────────────────────────────────────────────
@@@ -29,8 +32,11 @@ module.exports = function createNetRoutes(db, requireAuth) {
let t = input.trim();
// Strip protocol if URL
try {
-- if (/^https?:\/\//.test(t)) { t = new URL(t).hostname; }
++ if (/^https?:\/\//i.test(t)) { t = new URL(t).hostname; }
} catch {}
++ // [IPv6] with or without a port
++ const bracketed = /^\[([0-9a-f:.]+)\](?::\d+)?(?:\/.*)?$/i.exec(t);
++ if (bracketed) t = bracketed[1];
// Strip trailing dots, slashes
t = t.replace(/\/.*$/, '').replace(/\.+$/, '').toLowerCase();
// Strip port only if it looks like IPv4:port (not IPv6 which has multiple colons)
@@@ -59,7 +65,26 @@ module.exports = function createNetRoutes(db, requireAuth) {
}
function ok(res, data) { return res.json({ ok: true, ...data }); }
-- function fail(res, msg, status = 400) { return res.status(status).json({ ok: false, error: msg }); }
++ function fail(res, msg, status = 400, code) { return res.status(status).json({ ok: false, error: msg, ...(code ? { code } : {}) }); }
++
++ /** A guard refusal (403) or unresolvable name (400) as itself; anything else as `fallback` / 500. */
++ function failFrom(res, err, fallback) {
++ if (err instanceof TargetRefused) return fail(res, err.message, 403, err.code);
++ if (err && err.status >= 400 && err.status < 500) return fail(res, err.message, err.status);
++ return fail(res, (err && err.message) || fallback, 500);
++ }
++
++ /** A custom DNS server must be a public IP (optionally with a port). → 'ip' / 'ip:port', or throws. */
++ function checkDnsServer(raw) {
++ const s = String(raw).trim();
++ const m = /^\[([0-9a-f:.]+)\](?::(\d{1,5}))?$/i.exec(s) || (net.isIP(s) ? [s, s, null] : /^([0-9.]+):(\d{1,5})$/.exec(s));
++ if (!m || !net.isIP(m[1])) throw Object.assign(new Error('The DNS server must be an IP address'), { status: 400 });
++ const port = m[2] ? parseInt(m[2], 10) : null;
++ if (port !== null && (port < 1 || port > 65535)) throw Object.assign(new Error('Invalid DNS server port'), { status: 400 });
++ if (!egress.isAllowed(m[1])) throw new TargetRefused(`${m[1]} is not a public internet address; the network tools only reach public hosts`);
++ if (port === null) return m[1];
++ return net.isIPv6(m[1]) ? `[${m[1]}]:${port}` : `${m[1]}:${port}`;
++ }
/** Get config (live — reads DB each time for admin changes) */
function cfg() { return getNetConfig(db); }
@@@ -279,7 +304,10 @@ module.exports = function createNetRoutes(db, requireAuth) {
const validTypes = types.filter(t => c.dnsTypes.includes(t));
if (!validTypes.length) return fail(res, 'No valid record types specified');
-- const server = req.query.server || null; // custom DNS server
++ let server = null; // custom DNS server — public IPs only
++ if (req.query.server) {
++ try { server = checkDnsServer(req.query.server); } catch (err) { return failFrom(res, err, 'Invalid DNS server'); }
++ }
const results = {};
// Optionally try DNS-over-HTTPS for cleaner results
@@@ -411,25 +439,12 @@ module.exports = function createNetRoutes(db, requireAuth) {
const target = parseTarget(req.params.target || req.query.target);
if (!target) return fail(res, 'Please provide a domain');
const port = parseInt(req.query.port) || 443;
++ if (port < 1 || port > 65535) return fail(res, 'Invalid port');
-- // Resolve first to include IPs
-- let ip = target;
-- if (!isIP(target)) {
-- const addrs = await dns.resolve4(target).catch(() => []);
-- if (addrs.length) ip = addrs[0];
-- }
--
-- const cert = await new Promise((resolve, reject) => {
-- const socket = tls.connect({ host: target, port, servername: target, timeout: 10000 }, () => {
-- const peerCert = socket.getPeerCertificate(true);
-- const proto = socket.getProtocol();
-- const cipher = socket.getCipher();
-- socket.end();
-- resolve({ cert: peerCert, protocol: proto, cipher });
-- });
-- socket.on('error', reject);
-- socket.setTimeout(10000, () => { socket.destroy(new Error('Timeout')); });
-- });
++ // Resolve + check, then handshake with the checked address (SNI = the name).
++ const dest = await egress.resolve(target, { prefer: 4 });
++ const ip = dest.address;
++ const cert = await egress.tlsHandshake(dest, port, { timeoutMs: 10000 });
const c = cert.cert;
const now = Date.now();
@@@ -478,7 +493,7 @@ module.exports = function createNetRoutes(db, requireAuth) {
chain,
});
} catch (err) {
-- fail(res, err.message || 'SSL check failed', 500);
++ failFrom(res, err, 'SSL check failed');
}
});
@@@ -492,17 +507,13 @@ module.exports = function createNetRoutes(db, requireAuth) {
if (!target) return fail(res, 'Please provide a URL or domain');
if (!/^https?:\/\//.test(target)) target = `https://${target}`;
-- const ua = req.query.ua || 'Mozilla/5.0 (compatible; Net.OpenVibe/1.0)';
++ const ua = String(req.query.ua || 'Mozilla/5.0 (compatible; Net.OpenVibe/1.0)').replace(/[\r\n]/g, ' ').slice(0, 300);
const start = Date.now();
-- const r = await timedFetch(target, {
-- method: 'HEAD',
-- headers: { 'User-Agent': ua },
-- redirect: 'manual',
-- });
++ // One hop, not followed (the Redirects tool shows the chain).
++ const r = await egress.request(target, { method: 'HEAD', headers: { 'User-Agent': ua }, timeoutMs: 12000 });
const elapsed = Date.now() - start;
-- const headers = {};
-- r.headers.forEach((v, k) => { headers[k] = v; });
++ const headers = r.headers;
// Security header analysis
const security = {
@@@ -528,7 +539,7 @@ module.exports = function createNetRoutes(db, requireAuth) {
contentType: headers['content-type'] || null,
});
} catch (err) {
-- fail(res, err.message || 'Headers check failed', 500);
++ failFrom(res, err, 'Headers check failed');
}
});
@@@ -546,27 +557,38 @@ module.exports = function createNetRoutes(db, requireAuth) {
let current = target;
const maxHops = 15;
++ let refused = null;
for (let i = 0; i < maxHops; i++) {
const start = Date.now();
-- const r = await timedFetch(current, {
-- method: 'HEAD',
-- headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Net.OpenVibe/1.0)' },
-- redirect: 'manual',
-- });
++ let r;
++ try {
++ // Every hop goes through the guard again: a public site redirecting to an
++ // internal address stops here, before anything connects to it.
++ r = await egress.request(current, {
++ method: 'HEAD',
++ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Net.OpenVibe/1.0)' },
++ timeoutMs: 12000,
++ });
++ } catch (err) {
++ if (i === 0) throw err;
++ if (!(err instanceof TargetRefused) && !(err && err.status === 400)) throw err;
++ if (err instanceof TargetRefused) refused = err.message;
++ chain.push({ url: current, status: null, refused: err instanceof TargetRefused, error: err.message, ms: Date.now() - start });
++ break;
++ }
const elapsed = Date.now() - start;
chain.push({
url: current,
status: r.status,
statusText: r.statusText,
-- location: r.headers.get('location') || null,
-- server: r.headers.get('server') || null,
++ location: r.headers.location || null,
++ server: r.headers.server || null,
ms: elapsed,
});
-- if (r.status >= 300 && r.status < 400 && r.headers.get('location')) {
-- const loc = r.headers.get('location');
-- current = loc.startsWith('/') ? new URL(loc, current).href : loc;
++ if (r.status >= 300 && r.status < 400 && r.headers.location) {
++ current = new URL(r.headers.location, current).href;
} else {
break;
}
@@@ -577,9 +599,10 @@ module.exports = function createNetRoutes(db, requireAuth) {
finalUrl: chain[chain.length - 1]?.url || target,
hops: chain.length,
chain,
++ ...(refused ? { stopped: refused } : {}),
});
} catch (err) {
-- fail(res, err.message || 'Redirect check failed', 500);
++ failFrom(res, err, 'Redirect check failed');
}
});
@@@ -596,39 +619,16 @@ module.exports = function createNetRoutes(db, requireAuth) {
const ports = portsStr.split(',').map(p => parseInt(p.trim())).filter(p => p > 0 && p <= 65535).slice(0, 20);
if (!ports.length) return fail(res, 'No valid ports specified');
-- // Resolve domain
-- let ip = target;
-- if (!isIP(target)) {
-- const addrs = await dns.resolve4(target).catch(() => []);
-- if (!addrs.length) return fail(res, `Cannot resolve ${target}`);
-- ip = addrs[0];
-- }
++ // Resolve + check; every probe dials the checked address.
++ const dest = await egress.resolve(target, { prefer: 4 });
++ const ip = dest.address;
-- const results = await Promise.all(ports.map(port => {
-- return new Promise(resolve => {
-- const start = Date.now();
-- const socket = new net.Socket();
-- socket.setTimeout(5000);
-- socket.on('connect', () => {
-- const ms = Date.now() - start;
-- socket.destroy();
-- resolve({ port, status: 'open', ms });
-- });
-- socket.on('timeout', () => {
-- socket.destroy();
-- resolve({ port, status: 'filtered', ms: 5000 });
-- });
-- socket.on('error', (err) => {
-- const ms = Date.now() - start;
-- resolve({ port, status: err.code === 'ECONNREFUSED' ? 'closed' : 'filtered', ms });
-- });
-- socket.connect(port, ip);
-- });
-- }));
++ const results = await Promise.all(ports.map(port =>
++ egress.tcpProbe(ip, port, { timeoutMs: 5000 }).then(r => ({ port, status: r.status, ms: r.ms }))));
ok(res, { target, ip, ports: results });
} catch (err) {
-- fail(res, err.message || 'Port check failed', 500);
++ failFrom(res, err, 'Port check failed');
}
});
@@@ -642,34 +642,15 @@ module.exports = function createNetRoutes(db, requireAuth) {
if (!target) return fail(res, 'Please provide a host');
const count = Math.min(parseInt(req.query.count) || 4, 10);
-- // Resolve domain
-- let ip = target;
-- if (!isIP(target)) {
-- const addrs = await dns.resolve4(target).catch(() => []);
-- if (!addrs.length) return fail(res, `Cannot resolve ${target}`);
-- ip = addrs[0];
-- }
++ // Resolve + check; every ping dials the checked address.
++ const dest = await egress.resolve(target, { prefer: 4 });
++ const ip = dest.address;
// TCP ping (more reliable than ICMP from Node.js + doesn't require root)
const results = [];
for (let i = 0; i < count; i++) {
-- const start = Date.now();
-- try {
-- await new Promise((resolve, reject) => {
-- const socket = new net.Socket();
-- socket.setTimeout(5000);
-- socket.on('connect', () => {
-- const ms = Date.now() - start;
-- socket.destroy();
-- resolve(ms);
-- });
-- socket.on('timeout', () => { socket.destroy(); reject(new Error('timeout')); });
-- socket.on('error', reject);
-- socket.connect(443, ip);
-- }).then(ms => results.push({ seq: i + 1, ms, status: 'ok' }));
-- } catch (err) {
-- results.push({ seq: i + 1, ms: Date.now() - start, status: err.message });
-- }
++ const r = await egress.tcpProbe(ip, 443, { timeoutMs: 5000 });
++ results.push({ seq: i + 1, ms: r.ms, status: r.status === 'open' ? 'ok' : (r.error || r.status) });
// Small delay between pings
if (i < count - 1) await new Promise(r => setTimeout(r, 200));
}
@@@ -684,7 +665,7 @@ module.exports = function createNetRoutes(db, requireAuth) {
ok(res, { target, ip, count, results, stats });
} catch (err) {
-- fail(res, err.message || 'Ping failed', 500);
++ failFrom(res, err, 'Ping failed');
}
});
@@@ -697,6 +678,9 @@ module.exports = function createNetRoutes(db, requireAuth) {
const target = parseTarget(req.params.target || req.query.target);
if (!target) return fail(res, 'Please provide a domain, IP, or URL');
++ // Internal targets are refused outright; a name that does not resolve still gets DNS/RDAP.
++ try { await egress.resolve(target); } catch (err) { if (err instanceof TargetRefused) return failFrom(res, err); }
++
// Run multiple lookups in parallel
const [ipResult, dnsResult, rdapResult, sslResult, headersResult, rdnsResult] = await Promise.allSettled([
// IP/Geo
@@@ -738,35 +722,29 @@ module.exports = function createNetRoutes(db, requireAuth) {
// SSL (domain only)
(async () => {
if (isIP(target)) return null;
-- return new Promise((resolve) => {
-- const socket = tls.connect({ host: target, port: 443, servername: target, timeout: 8000 }, () => {
-- const c = socket.getPeerCertificate();
-- const proto = socket.getProtocol();
-- socket.end();
-- const validTo = new Date(c.valid_to);
-- resolve({
-- subject: c.subject?.CN,
-- issuer: c.issuer?.CN || c.issuer?.O,
-- validFrom: c.valid_from,
-- validTo: c.valid_to,
-- daysLeft: Math.ceil((validTo - Date.now()) / 86400000),
-- protocol: proto,
-- sans: c.subjectaltname ? c.subjectaltname.split(', ').map(s => s.replace('DNS:', '')).slice(0, 10) : [],
-- });
-- });
-- socket.on('error', () => resolve(null));
-- socket.setTimeout(8000, () => { socket.destroy(); resolve(null); });
-- });
++ try {
++ const dest = await egress.resolve(target, { prefer: 4 });
++ const { cert: c, protocol: proto } = await egress.tlsHandshake(dest, 443, { timeoutMs: 8000, detailed: false });
++ const validTo = new Date(c.valid_to);
++ return {
++ subject: c.subject?.CN,
++ issuer: c.issuer?.CN || c.issuer?.O,
++ validFrom: c.valid_from,
++ validTo: c.valid_to,
++ daysLeft: Math.ceil((validTo - Date.now()) / 86400000),
++ protocol: proto,
++ sans: c.subjectaltname ? c.subjectaltname.split(', ').map(s => s.replace('DNS:', '')).slice(0, 10) : [],
++ };
++ } catch { return null; }
})(),
-- // Headers
++ // Headers (redirects followed, each hop re-checked)
(async () => {
if (isIP(target)) return null;
const url = `https://${target}`;
const start = Date.now();
-- const r = await timedFetch(url, { method: 'HEAD', headers: { 'User-Agent': 'Net.OpenVibe/1.0' }, redirect: 'follow' });
++ const r = await egress.follow(url, { method: 'HEAD', headers: { 'User-Agent': 'Net.OpenVibe/1.0' }, timeoutMs: 12000, maxRedirects: 5 });
const ms = Date.now() - start;
-- const headers = {};
-- r.headers.forEach((v, k) => { headers[k] = v; });
++ const headers = r.headers;
return { status: r.status, ms, headers, server: headers['server'] || null };
})(),
// Reverse DNS
ddiff --git a/apps/gateway/test/net-ssrf.test.js b/apps/gateway/test/net-ssrf.test.js
nnew file mode 100644
iindex 0000000..b17490f
---- /dev/null
++++ b/apps/gateway/test/net-ssrf.test.js
@@@ -0,0 +1,151 @@
++'use strict';
++// SSRF regression: every Net.OpenVibe / Dev.OpenVibe tool that connects to a visitor's target
++// refuses loopback, private, link-local (metadata), CGNAT, … targets with 403, including a name
++// that resolves to one and a redirect to one; a public target still works. The resolver and the
++// transports are mocks: nothing here touches a real network.
++const assert = require('assert');
++const express = require('express');
++const { EventEmitter } = require('events');
++const { Readable } = require('stream');
++const { createEgress } = require('../../_shared/egress');
++const createNetRoutes = require('../server/net/routes');
++const createDevRoutes = require('../server/dev/routes');
++
++const DNS = {
++ 'public.example': ['93.184.216.34'],
++ 'redirector.example': ['93.184.216.35'],
++ 'rebind.example': ['127.0.0.1'],
++ 'meta.example': ['169.254.169.254'],
++ 'lan.example': ['192.168.1.20'],
++};
++const lookup = (host, _opts, cb) => {
++ const a = DNS[host];
++ if (!a) return cb(Object.assign(new Error('not found'), { code: 'ENOTFOUND' }));
++ cb(null, a.map(address => ({ address, family: 4 })));
++};
++
++// Every connection attempt, by the address actually dialled.
++const dialled = [];
++function fakeSocket(address) {
++ const s = new EventEmitter();
++ s.setTimeout = () => {}; s.destroy = () => {}; s.end = () => {};
++ process.nextTick(() => s.emit('connect'));
++ dialled.push(address);
++ return s;
++}
++function fakeHttp(opts, cb) {
++ const req = new EventEmitter();
++ req.destroy = () => {};
++ req.end = () => {
++ const answer = (address) => {
++ dialled.push(address);
++ const redirects = { '/to-loopback': 'http://127.0.0.1:3000/admin', '/to-rebind': 'http://rebind.example:4000/', '/to-meta': 'http://169.254.169.254/latest/meta-data/', '/to-public': 'https://public.example/' };
++ const loc = opts.hostname === 'redirector.example' && redirects[opts.path];
++ const res = Readable.from(loc ? [] : [Buffer.from('<html><head><meta property="og:title" content="Hello"><title>t</title></head></html>')]);
++ Object.assign(res, loc
++ ? { statusCode: 302, statusMessage: 'Found', headers: { location: loc } }
++ : { statusCode: 200, statusMessage: 'OK', headers: { server: 'mock', 'content-type': 'text/html', 'strict-transport-security': 'max-age=1' } });
++ cb(res);
++ };
++ // Like a real socket: a name goes through the (pinned) lookup, a literal is dialled as is.
++ if (opts.lookup) opts.lookup(opts.hostname, {}, (err, address) => err ? req.emit('error', err) : answer(address));
++ else answer(opts.hostname);
++ };
++ return req;
++}
++const egress = createEgress({
++ lookup,
++ tcpConnect: ({ host }) => fakeSocket(host),
++ tlsConnect: (opts) => { const s = fakeSocket(opts.host); s.getPeerCertificate = () => ({}); return s; },
++ httpRequest: fakeHttp,
++ httpsRequest: fakeHttp,
++});
++
++(async () => {
++ const app = express();
++ app.use('/api/net', createNetRoutes(null, null, { egress }));
++ app.use('/api/dev', createDevRoutes(null, null, { egress }));
++ const srv = await new Promise(r => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
++ const base = `http://127.0.0.1:${srv.address().port}`;
++ const get = async (p) => { const r = await fetch(base + p); return { status: r.status, body: await r.json() }; };
++ const q = encodeURIComponent;
++
++ // ── Refused: hosts and URLs that mean this machine or a private network ──
++ const hosts = ['localhost', '127.0.0.1', '[::1]', '::1', '10.0.0.8', '192.168.1.1', '172.20.0.5', '169.254.169.254', '100.64.0.1',
++ '0.0.0.0', '::ffff:127.0.0.1', 'fd00::1', '2130706433', 'rebind.example', 'meta.example', 'lan.example'];
++ for (const h of hosts) {
++ for (const path of [`/api/net/port?target=${q(h)}&ports=3000,4000,4910,8000`, `/api/net/ping?target=${q(h)}&count=1`,
++ `/api/net/ssl?target=${q(h)}`, `/api/net/lookup?target=${q(h)}`]) {
++ dialled.length = 0;
++ const r = await get(path);
++ assert.equal(r.status, 403, `${path} → ${r.status} ${JSON.stringify(r.body)}`);
++ assert.equal(r.body.ok, false);
++ assert.equal(r.body.code, 'tools.net.target_not_public');
++ assert.match(r.body.error, /not a public internet address/);
++ assert.deepEqual(dialled, [], `${path} made no connection`);
++ }
++ }
++ const urls = ['http://localhost:3000/', 'http://127.0.0.1:4000/api', 'http://[::1]:8000/', 'http://10.1.1.1/', 'http://192.168.0.1/',
++ 'http://169.254.169.254/latest/meta-data/', 'https://rebind.example/', 'http://meta.example/', 'http://0x7f000001:3000/', 'localhost:4100'];
++ for (const u of urls) {
++ for (const path of [`/api/net/headers?target=${q(u)}`, `/api/net/redirects?target=${q(u)}`, `/api/dev/opengraph?url=${q(u)}`]) {
++ dialled.length = 0;
++ const r = await get(path);
++ assert.equal(r.status, 403, `${path} → ${r.status} ${JSON.stringify(r.body)}`);
++ assert.match(r.body.error, /not a public internet address/);
++ assert.deepEqual(dialled, [], `${path} made no connection`);
++ }
++ }
++ for (const s of ['127.0.0.1', '10.0.0.53:53', '[::1]:53', '169.254.169.254']) {
++ const r = await get(`/api/net/dns?target=example.com&server=${q(s)}`);
++ assert.equal(r.status, 403, `dns server ${s}`);
++ }
++ assert.equal((await get('/api/net/dns?target=example.com&server=resolver.example')).status, 400, 'a DNS server must be an IP');
++ console.log('internal targets refused: ok');
++
++ // ── Redirects to internal addresses are re-checked and never dialled ──
++ for (const hop of ['/to-loopback', '/to-rebind', '/to-meta']) {
++ dialled.length = 0;
++ const r = await get(`/api/net/redirects?target=${q(`https://redirector.example${hop}`)}`);
++ assert.equal(r.status, 200);
++ assert.equal(r.body.chain.length, 2);
++ assert.equal(r.body.chain[0].status, 302);
++ assert.equal(r.body.chain[1].refused, true);
++ assert.match(r.body.stopped, /not a public internet address/);
++ assert.deepEqual(dialled, ['93.184.216.35'], `${hop}: only the public hop was dialled`);
++
++ dialled.length = 0;
++ const og = await get(`/api/dev/opengraph?url=${q(`https://redirector.example${hop}`)}`);
++ assert.equal(og.status, 403, `opengraph ${hop}`);
++ assert.deepEqual(dialled, ['93.184.216.35']);
++ }
++ // Headers does not follow at all.
++ dialled.length = 0;
++ const h302 = await get(`/api/net/headers?target=${q('https://redirector.example/to-loopback')}`);
++ assert.equal(h302.status, 200); assert.equal(h302.body.status, 302);
++ assert.deepEqual(dialled, ['93.184.216.35']);
++ console.log('redirect hops re-checked: ok');
++
++ // ── A public target still works, dialled at its checked address ──
++ dialled.length = 0;
++ const port = await get('/api/net/port?target=public.example&ports=443,80');
++ assert.equal(port.status, 200);
++ assert.equal(port.body.ip, '93.184.216.34');
++ assert.deepEqual(port.body.ports.map(p => p.status), ['open', 'open']);
++ assert.deepEqual(dialled, ['93.184.216.34', '93.184.216.34']);
++ const lit = await get('/api/net/port?target=93.184.216.34&ports=22');
++ assert.equal(lit.status, 200);
++ const ping = await get('/api/net/ping?target=public.example&count=2');
++ assert.equal(ping.status, 200); assert.equal(ping.body.stats.loss, 0);
++ const hdr = await get(`/api/net/headers?target=${q('public.example')}`);
++ assert.equal(hdr.status, 200); assert.equal(hdr.body.status, 200); assert.equal(hdr.body.server, 'mock');
++ assert.equal(hdr.body.security.hasHSTS, true);
++ const red = await get(`/api/net/redirects?target=${q('https://redirector.example/to-public')}`);
++ assert.equal(red.status, 200); assert.deepEqual(red.body.chain.map(c => c.status), [302, 200]); assert.equal(red.body.stopped, undefined);
++ const og = await get(`/api/dev/opengraph?url=${q('https://redirector.example/to-public')}`);
++ assert.equal(og.status, 200); assert.equal(og.body.tags['og:title'], 'Hello');
++ console.log('public targets allowed: ok');
++
++ srv.closeAllConnections(); srv.close();
++ console.log('net/dev tools SSRF guard: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });