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)
Changes
@@@ -23,7 +23,9 @@ apps/├── text # Text.OpenVibe + Logo.OpenVibe — text generators & logo makers└── docs # Docs.OpenVibe — PDF & document toolsapps/_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)```nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });@@@ -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;}@@@ -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 thisfunction 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}` });}});@@@ -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 URLtry {-- 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, slashest = 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 analysisconst 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 pingsif (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 parallelconst [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 DNSnnew file mode 100644@@@ -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); });
@@ -23,7 +23,9 @@ apps/
@@ -23,7 +23,9 @@ apps/
├── text # Text.OpenVibe + Logo.OpenVibe — text generators & logo makers
├── text # Text.OpenVibe + Logo.OpenVibe — text generators & logo makers
└── docs # Docs.OpenVibe — PDF & document tools
└── docs # Docs.OpenVibe — PDF & document tools
apps/_shared # code the apps require by relative path: host roles, internal auth, the job runtime (jobs/),
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)
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)
(openvibe-shared is a pinned OpenVibe.Shared release in each app's package.json; no vendor/ copy)
```
```
new file mode 100644
new file mode 100644
@@ -0,0 +1,279 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -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); });
@@ -1171,10 +1171,10 @@
@@ -1171,10 +1171,10 @@
${chain.map((h, i) => `<tr>
${chain.map((h, i) => `<tr>
<td class="val">${i + 1}</td>
<td class="val">${i + 1}</td>
<td class="val" style="max-width:400px;overflow:hidden;text-overflow:ellipsis">${esc(h.url)}</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>
<td class="val">${h.ms}ms</td>
</tr>`).join('')}
</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;
el.innerHTML = html;
}
}
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
const { Router } = require('express');
const { Router } = require('express');
const crypto = require('crypto');
const crypto = require('crypto');
const { DEV_TOOLS } = require('./config');
const { DEV_TOOLS } = require('./config');
+const { createEgress, TargetRefused } = require('../../../_shared/egress');
// ── In-memory webhook bin storage ────────────────────────────
// ── In-memory webhook bin storage ────────────────────────────
const webhookBins = new Map();
const webhookBins = new Map();
@@ -22,21 +23,10 @@ function cleanupBins() {
@@ -22,21 +23,10 @@ function cleanupBins() {
if (now - bin.created > WEBHOOK_BIN_TTL) webhookBins.delete(id);
if (now - bin.created > WEBHOOK_BIN_TTL) webhookBins.delete(id);
}
}
}
}
-setInterval(cleanupBins, 5 * 60 * 1000);
+setInterval(cleanupBins, 5 * 60 * 1000).unref();
// ── Helpers ──────────────────────────────────────────────────
// ── 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) {
function extractOGTags(html) {
const tags = {};
const tags = {};
@@ -60,8 +50,10 @@ function extractOGTags(html) {
@@ -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 router = Router();
+ const egress = opts.egress || createEgress();
// Optional auth — attaches req.user if token present, but doesn't block.
// Optional auth — attaches req.user if token present, but doesn't block.
// Verifies the shared ov_token OFFLINE against the Network public key.
// Verifies the shared ov_token OFFLINE against the Network public key.
@@ -102,13 +94,19 @@ module.exports = function createDevRoutes(db, requireAuth) {
@@ -102,13 +94,19 @@ module.exports = function createDevRoutes(db, requireAuth) {
}
}
try {
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: {
headers: {
'User-Agent': 'OpenVibeOpenGraph/1.0 (https://opengraph.openvibe.tools)',
'User-Agent': 'OpenVibeOpenGraph/1.0 (https://opengraph.openvibe.tools)',
'Accept': 'text/html,application/xhtml+xml',
'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);
const tags = extractOGTags(html);
// Build structured result
// Build structured result
@@ -141,6 +139,8 @@ module.exports = function createDevRoutes(db, requireAuth) {
@@ -141,6 +139,8 @@ module.exports = function createDevRoutes(db, requireAuth) {
res.json(result);
res.json(result);
} catch (err) {
} 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}` });
res.status(502).json({ error: `Failed to fetch: ${err.message}` });
}
}
});
});
@@ -11,15 +11,18 @@
@@ -11,15 +11,18 @@
const express = require('express');
const express = require('express');
const dns = require('dns').promises;
const dns = require('dns').promises;
const { URL } = require('url');
const { URL } = require('url');
-const tls = require('tls');
const net = require('net');
const net = require('net');
-const https = require('https');
-const http = require('http');
const { getNetConfig, NET_TOOLS } = require('./config');
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 ──────────────────────────────────────────────
// ── Helpers ──────────────────────────────────────────────
@@ -29,8 +32,11 @@ module.exports = function createNetRoutes(db, requireAuth) {
@@ -29,8 +32,11 @@ module.exports = function createNetRoutes(db, requireAuth) {
let t = input.trim();
let t = input.trim();
// Strip protocol if URL
// Strip protocol if URL
try {
try {
- if (/^https?:\/\//.test(t)) { t = new URL(t).hostname; }
+ if (/^https?:\/\//i.test(t)) { t = new URL(t).hostname; }
} catch {}
} 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
// Strip trailing dots, slashes
t = t.replace(/\/.*$/, '').replace(/\.+$/, '').toLowerCase();
t = t.replace(/\/.*$/, '').replace(/\.+$/, '').toLowerCase();
// Strip port only if it looks like IPv4:port (not IPv6 which has multiple colons)
// 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) {
@@ -59,7 +65,26 @@ module.exports = function createNetRoutes(db, requireAuth) {
}
}
function ok(res, data) { return res.json({ ok: true, ...data }); }
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) */
/** Get config (live — reads DB each time for admin changes) */
function cfg() { return getNetConfig(db); }
function cfg() { return getNetConfig(db); }
@@ -279,7 +304,10 @@ module.exports = function createNetRoutes(db, requireAuth) {
@@ -279,7 +304,10 @@ module.exports = function createNetRoutes(db, requireAuth) {
const validTypes = types.filter(t => c.dnsTypes.includes(t));
const validTypes = types.filter(t => c.dnsTypes.includes(t));
if (!validTypes.length) return fail(res, 'No valid record types specified');
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 = {};
const results = {};
// Optionally try DNS-over-HTTPS for cleaner results
// Optionally try DNS-over-HTTPS for cleaner results
@@ -411,25 +439,12 @@ module.exports = function createNetRoutes(db, requireAuth) {
@@ -411,25 +439,12 @@ module.exports = function createNetRoutes(db, requireAuth) {
const target = parseTarget(req.params.target || req.query.target);
const target = parseTarget(req.params.target || req.query.target);
if (!target) return fail(res, 'Please provide a domain');
if (!target) return fail(res, 'Please provide a domain');
const port = parseInt(req.query.port) || 443;
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 c = cert.cert;
const now = Date.now();
const now = Date.now();
@@ -478,7 +493,7 @@ module.exports = function createNetRoutes(db, requireAuth) {
@@ -478,7 +493,7 @@ module.exports = function createNetRoutes(db, requireAuth) {
chain,
chain,
});
});
} catch (err) {
} 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) {
@@ -492,17 +507,13 @@ module.exports = function createNetRoutes(db, requireAuth) {
if (!target) return fail(res, 'Please provide a URL or domain');
if (!target) return fail(res, 'Please provide a URL or domain');
if (!/^https?:\/\//.test(target)) target = `https://${target}`;
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 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 elapsed = Date.now() - start;
- const headers = {};
- r.headers.forEach((v, k) => { headers[k] = v; });
+ const headers = r.headers;
// Security header analysis
// Security header analysis
const security = {
const security = {
@@ -528,7 +539,7 @@ module.exports = function createNetRoutes(db, requireAuth) {
@@ -528,7 +539,7 @@ module.exports = function createNetRoutes(db, requireAuth) {
contentType: headers['content-type'] || null,
contentType: headers['content-type'] || null,
});
});
} catch (err) {
} 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) {
@@ -546,27 +557,38 @@ module.exports = function createNetRoutes(db, requireAuth) {
let current = target;
let current = target;
const maxHops = 15;
const maxHops = 15;
+ let refused = null;
for (let i = 0; i < maxHops; i++) {
for (let i = 0; i < maxHops; i++) {
const start = Date.now();
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;
const elapsed = Date.now() - start;
chain.push({
chain.push({
url: current,
url: current,
status: r.status,
status: r.status,
statusText: r.statusText,
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,
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 {
} else {
break;
break;
}
}
@@ -577,9 +599,10 @@ module.exports = function createNetRoutes(db, requireAuth) {
@@ -577,9 +599,10 @@ module.exports = function createNetRoutes(db, requireAuth) {
finalUrl: chain[chain.length - 1]?.url || target,
finalUrl: chain[chain.length - 1]?.url || target,
hops: chain.length,
hops: chain.length,
chain,
chain,
+ ...(refused ? { stopped: refused } : {}),
});
});
} catch (err) {
} 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) {
@@ -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);
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');
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 });
ok(res, { target, ip, ports: results });
} catch (err) {
} 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) {
@@ -642,34 +642,15 @@ module.exports = function createNetRoutes(db, requireAuth) {
if (!target) return fail(res, 'Please provide a host');
if (!target) return fail(res, 'Please provide a host');
const count = Math.min(parseInt(req.query.count) || 4, 10);
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)
// TCP ping (more reliable than ICMP from Node.js + doesn't require root)
const results = [];
const results = [];
for (let i = 0; i < count; i++) {
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
// Small delay between pings
if (i < count - 1) await new Promise(r => setTimeout(r, 200));
if (i < count - 1) await new Promise(r => setTimeout(r, 200));
}
}
@@ -684,7 +665,7 @@ module.exports = function createNetRoutes(db, requireAuth) {
@@ -684,7 +665,7 @@ module.exports = function createNetRoutes(db, requireAuth) {
ok(res, { target, ip, count, results, stats });
ok(res, { target, ip, count, results, stats });
} catch (err) {
} catch (err) {
- fail(res, err.message || 'Ping failed', 500);
+ failFrom(res, err, 'Ping failed');
}
}
});
});
@@ -697,6 +678,9 @@ module.exports = function createNetRoutes(db, requireAuth) {
@@ -697,6 +678,9 @@ module.exports = function createNetRoutes(db, requireAuth) {
const target = parseTarget(req.params.target || req.query.target);
const target = parseTarget(req.params.target || req.query.target);
if (!target) return fail(res, 'Please provide a domain, IP, or URL');
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
// Run multiple lookups in parallel
const [ipResult, dnsResult, rdapResult, sslResult, headersResult, rdnsResult] = await Promise.allSettled([
const [ipResult, dnsResult, rdapResult, sslResult, headersResult, rdnsResult] = await Promise.allSettled([
// IP/Geo
// IP/Geo
@@ -738,35 +722,29 @@ module.exports = function createNetRoutes(db, requireAuth) {
@@ -738,35 +722,29 @@ module.exports = function createNetRoutes(db, requireAuth) {
// SSL (domain only)
// SSL (domain only)
(async () => {
(async () => {
if (isIP(target)) return null;
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 () => {
(async () => {
if (isIP(target)) return null;
if (isIP(target)) return null;
const url = `https://${target}`;
const url = `https://${target}`;
const start = Date.now();
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 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 };
return { status: r.status, ms, headers, server: headers['server'] || null };
})(),
})(),
// Reverse DNS
// Reverse DNS
new file mode 100644
new file mode 100644
@@ -0,0 +1,151 @@
@@ -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); });