'use strict'; // Outgoing requests follow http_proxy / https_proxy / no_proxy on their own, // without NODE_USE_ENV_PROXY, and nothing changes when none of them is set. const assert = require('node:assert/strict'); const { execFileSync } = require('node:child_process'); const fs = require('node:fs'); const http = require('node:http'); const https = require('node:https'); const net = require('node:net'); const os = require('node:os'); const path = require('node:path'); const test = require('node:test'); const zlib = require('node:zlib'); const outboundProxy = require('../src/outboundProxy'); const { resolveCallbackDestination } = require('../src/webhookCallback'); const { safeGet } = require('../src/safeFetch'); const PROXY_VARS = ['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', 'no_proxy', 'NO_PROXY', 'OUTBOUND_PROXY_RESOLVES_NAMES']; async function withEnv(vars, fn) { const saved = {}; for (const key of PROXY_VARS) { saved[key] = process.env[key]; delete process.env[key]; } Object.assign(process.env, vars); try { return await fn(); } finally { for (const key of PROXY_VARS) { if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key]; } } } function listen(server) { return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server))); } function close(server) { const closed = new Promise((resolve) => server.close(() => resolve())); server.closeAllConnections?.(); for (const socket of server.tunnels || []) socket.destroy(); return closed; } // A forwarding proxy like tinyproxy: absolute-form requests for http:// and // CONNECT tunnels for https://. It records what it was asked for, and serves // names ending in .test from 127.0.0.1 (names Haven itself cannot resolve, // like public sites inside a locked-down container). async function startProxy({ refuse = false } = {}) { const seen = []; const server = http.createServer((req, res) => { seen.push({ method: req.method, target: req.url, auth: req.headers['proxy-authorization'] }); const target = new URL(req.url); if (target.hostname.endsWith('.test')) target.hostname = '127.0.0.1'; const headers = { ...req.headers }; delete headers['proxy-authorization']; const upstream = http.request(target, { method: req.method, headers }, (up) => { res.writeHead(up.statusCode, up.headers); up.pipe(res); }); upstream.on('error', () => { res.writeHead(502); res.end(); }); req.pipe(upstream); }); const tunnels = new Set(); server.tunnels = tunnels; server.on('connect', (req, socket, head) => { tunnels.add(socket); seen.push({ method: 'CONNECT', target: req.url, auth: req.headers['proxy-authorization'] }); if (refuse) { socket.end('HTTP/1.1 403 Filtered\r\n\r\n'); return; } const [name, port] = req.url.split(':'); const host = name.endsWith('.test') ? '127.0.0.1' : name; const upstream = net.connect(Number(port), host, () => { tunnels.add(upstream); socket.write('HTTP/1.1 200 Connection established\r\n\r\n'); if (head.length) upstream.write(head); upstream.pipe(socket); socket.pipe(upstream); }); upstream.on('error', () => socket.destroy()); socket.on('error', () => upstream.destroy()); }); await listen(server); return { server, seen, url: `http://127.0.0.1:${server.address().port}` }; } function selfSignedCert() { try { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'haven-proxy-test-')); const key = path.join(dir, 'key.pem'); const cert = path.join(dir, 'cert.pem'); execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', key, '-out', cert, '-days', '1', '-subj', '/CN=localhost'], { stdio: 'ignore' }); return { key: fs.readFileSync(key), cert: fs.readFileSync(cert) }; } catch { return null; } } test('with no proxy variables nothing is routed or replaced', async () => { await withEnv({}, async () => { const before = globalThis.fetch; assert.equal(outboundProxy.proxyFor('https://example.com/'), null); assert.equal(outboundProxy.agentFor('https://example.com/'), undefined); assert.equal(outboundProxy.agentFor('http://example.com/'), undefined); assert.equal(outboundProxy.install({ log() {}, warn() {} }), false); assert.equal(globalThis.fetch, before); }); }); test('the proxy for each scheme comes from its own variable, lower case first', async () => { await withEnv({ HTTPS_PROXY: 'http://upper:1', https_proxy: 'http://lower:2', HTTP_PROXY: 'plain:3' }, () => { assert.equal(outboundProxy.proxyFor('https://example.com/').host, 'lower:2'); assert.equal(outboundProxy.proxyFor('wss://gateway.example.com/').host, 'lower:2'); assert.equal(outboundProxy.proxyFor('http://example.com/').host, 'plain:3'); assert.equal(outboundProxy.proxyFor('ftp://example.com/'), null); }); await withEnv({ HTTPS_PROXY: 'http://only-https:1' }, () => { assert.equal(outboundProxy.proxyFor('http://example.com/'), null); }); }); test('an unusable proxy address fails instead of going direct', async () => { await withEnv({ HTTPS_PROXY: 'socks5://proxy:1080' }, async () => { assert.throws(() => outboundProxy.proxyFor('https://example.com/'), /only http:\/\/ proxies/); await assert.rejects(outboundProxy.fetch('https://example.com/'), /fetch failed/); }); }); test('proxy credentials are hidden when the proxy address is logged', () => { assert.equal(outboundProxy.redact('http://user:secret@proxy:8080'), 'http://***@proxy:8080'); assert.equal(outboundProxy.redact('user:secret@proxy:8080'), '***@proxy:8080'); assert.equal(outboundProxy.redact('http://proxy:8080'), 'http://proxy:8080'); assert.equal(outboundProxy.redact('proxy:8080'), 'proxy:8080'); assert.equal(outboundProxy.redact('http://user:p@ss@proxy:8080'), 'http://***@proxy:8080'); }); test('no_proxy matches names, subdomains, ports, addresses and CIDR ranges', async () => { const noProxy = 'localhost,127.0.0.1,::1,172.16.0.0/12,10.0.0.0/8,.mcpc.cloud,haven,*.wild.test,ported.test:8443,[fd00::1]'; await withEnv({ HTTPS_PROXY: 'http://tinyproxy:8888', HTTP_PROXY: 'http://tinyproxy:8888', NO_PROXY: noProxy }, () => { const direct = (u) => outboundProxy.proxyFor(u) === null; assert.ok(direct('http://localhost:3000/')); assert.ok(direct('http://127.0.0.1/')); assert.ok(direct('http://[::1]/')); assert.ok(direct('http://172.18.0.4/')); assert.ok(direct('http://10.1.2.3/')); assert.ok(direct('https://haven.mcpc.cloud/')); assert.ok(direct('https://mcpc.cloud/')); assert.ok(direct('http://haven:3000/')); assert.ok(direct('https://a.wild.test/')); assert.ok(direct('https://ported.test:8443/')); assert.ok(direct('http://[fd00::1]/')); assert.ok(!direct('https://api.fxtwitter.com/')); assert.ok(!direct('https://notmcpc.cloud/')); assert.ok(!direct('http://172.32.0.1/')); assert.ok(!direct('https://ported.test/')); assert.ok(!direct('https://havenx/')); }); await withEnv({ HTTPS_PROXY: 'http://tinyproxy:8888', no_proxy: '*' }, () => { assert.equal(outboundProxy.proxyFor('https://example.com/'), null); }); }); test('fetch goes through the proxy: redirects, compression, bodies, credentials', async () => { const site = await listen(http.createServer((req, res) => { if (req.url === '/start') { res.writeHead(302, { Location: '/gz' }); return res.end(); } if (req.url === '/gz') { res.writeHead(200, { 'Content-Encoding': 'gzip', 'Content-Type': 'text/plain', 'Set-Cookie': ['a=1', 'b=2'] }); return res.end(zlib.gzipSync('hello through the proxy')); } let body = ''; req.on('data', (c) => { body += c; }); req.on('end', () => { res.writeHead(201, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ method: req.method, body, type: req.headers['content-type'] })); }); })); const proxy = await startProxy(); const proxyWithAuth = proxy.url.replace('http://', 'http://user:p%40ss@'); const base = `http://127.0.0.1:${site.address().port}`; try { await withEnv({ HTTP_PROXY: proxyWithAuth }, async () => { const got = await outboundProxy.fetch(`${base}/start`); assert.equal(got.status, 200); assert.equal(got.redirected, true); assert.equal(got.url, `${base}/gz`); assert.equal(await got.text(), 'hello through the proxy'); assert.deepEqual(got.headers.getSetCookie(), ['a=1', 'b=2']); const posted = await outboundProxy.fetch(`${base}/echo`, { method: 'POST', body: new URLSearchParams({ q: 'x y' }) }); assert.equal(posted.status, 201); assert.deepEqual(await posted.json(), { method: 'POST', body: 'q=x+y', type: 'application/x-www-form-urlencoded;charset=UTF-8' }); }); assert.deepEqual(proxy.seen.map((s) => s.target), [`${base}/start`, `${base}/gz`, `${base}/echo`]); assert.equal(proxy.seen[0].auth, `Basic ${Buffer.from('user:p@ss').toString('base64')}`); } finally { await close(proxy.server); await close(site); } }); test('fetch to a no_proxy host is left to the normal fetch', async () => { const site = await listen(http.createServer((req, res) => res.end('direct'))); const proxy = await startProxy(); try { await withEnv({ HTTP_PROXY: proxy.url, NO_PROXY: '127.0.0.1' }, async () => { const got = await outboundProxy.fetch(`http://127.0.0.1:${site.address().port}/`); assert.equal(await got.text(), 'direct'); }); assert.equal(proxy.seen.length, 0); } finally { await close(proxy.server); await close(site); } }); test('an abort reaches a request that is going through the proxy', async () => { const site = await listen(http.createServer(() => { /* never answers */ })); const proxy = await startProxy(); try { await withEnv({ HTTP_PROXY: proxy.url }, async () => { await assert.rejects( outboundProxy.fetch(`http://127.0.0.1:${site.address().port}/`, { signal: AbortSignal.timeout(200) }), (err) => err.name === 'TimeoutError' ); }); } finally { await close(proxy.server); await close(site); } }); test('https goes through a CONNECT tunnel with TLS to the site inside it', async (t) => { const pair = selfSignedCert(); if (!pair) return t.skip('openssl not available'); const site = await listen(https.createServer(pair, (req, res) => res.end(`tls ok ${req.url}`))); const proxy = await startProxy(); const target = `https://127.0.0.1:${site.address().port}/path?q=1`; try { await withEnv({ HTTPS_PROXY: proxy.url }, async () => { const body = await new Promise((resolve, reject) => { const req = https.get(target, { agent: outboundProxy.agentFor(target), rejectUnauthorized: false }, (res) => { let data = ''; res.on('data', (c) => { data += c; }); res.on('end', () => resolve(data)); }); req.on('error', reject); }); assert.equal(body, 'tls ok /path?q=1'); }); assert.deepEqual(proxy.seen.map((s) => `${s.method} ${s.target}`), [`CONNECT 127.0.0.1:${site.address().port}`]); } finally { await close(proxy.server); await close(site); } }); test('https to an IP address checks the certificate against that address', async (t) => { const pair = selfSignedCert(); if (!pair) return t.skip('openssl not available'); const site = await listen(https.createServer(pair, (req, res) => res.end('should not be reached'))); const proxy = await startProxy(); const target = `https://127.0.0.1:${site.address().port}/`; try { await withEnv({ HTTPS_PROXY: proxy.url }, async () => { // The certificate is for localhost, so it must not be accepted for 127.0.0.1. await assert.rejects(new Promise((resolve, reject) => { https.get(target, { agent: outboundProxy.agentFor(target), ca: pair.cert }, resolve).on('error', reject); }), { code: 'ERR_TLS_CERT_ALTNAME_INVALID' }); }); } finally { await close(proxy.server); await close(site); } }); test('an abort also cancels a CONNECT the proxy has not answered yet', async () => { const proxy = await listen(http.createServer()); const tunnelClosed = new Promise((resolve) => { proxy.on('connect', (req, socket) => socket.on('end', () => { socket.destroy(); resolve(); })); }); try { await withEnv({ HTTPS_PROXY: `http://127.0.0.1:${proxy.address().port}` }, async () => { await assert.rejects(outboundProxy.fetch('https://slow.example/', { signal: AbortSignal.timeout(200) }), (err) => err.name === 'TimeoutError'); await tunnelClosed; }); } finally { await close(proxy); } }); test('a tunnel the proxy refuses fails with the reason', async () => { const proxy = await startProxy({ refuse: true }); try { await withEnv({ HTTPS_PROXY: proxy.url }, async () => { await assert.rejects(outboundProxy.fetch('https://blocked.example/'), (err) => /HTTP 403/.test(err.cause?.message)); }); } finally { await close(proxy.server); } }); test('member URLs through the proxy: refused when they point inward, pinned when they resolve, proxy-resolved only on opt-in', async () => { const unresolvable = async () => { throw Object.assign(new Error('queryA ESERVFAIL'), { code: 'ESERVFAIL' }); }; await withEnv({ HTTPS_PROXY: 'http://tinyproxy:8888' }, async () => { // A name this machine cannot resolve is refused unless the host opts in. await assert.rejects(resolveCallbackDestination('https://x.com/user/status/1', { lookup: unresolvable }), { code: 'ERR_UNSAFE_CALLBACK_URL' }); // A name that resolves goes through the proxy, pinned to the checked address. const pinned = await resolveCallbackDestination('https://x.com/user/status/1', { lookup: async () => [{ address: '93.184.216.34', family: 4 }] }); assert.equal(pinned.proxy.host, 'tinyproxy:8888'); assert.equal(pinned.address, '93.184.216.34'); assert.equal(pinned.pinned, true); }); await withEnv({ HTTPS_PROXY: 'http://tinyproxy:8888', HTTP_PROXY: 'http://tinyproxy:8888', OUTBOUND_PROXY_RESOLVES_NAMES: 'true' }, async () => { const dest = await resolveCallbackDestination('https://x.com/user/status/1', { lookup: unresolvable }); assert.equal(dest.proxy.host, 'tinyproxy:8888'); assert.equal(dest.address, undefined); const inward = async () => [{ address: '172.18.0.4', family: 4 }]; await assert.rejects(resolveCallbackDestination('http://socket-proxy:2375/', { lookup: inward }), { code: 'ERR_UNSAFE_CALLBACK_URL' }); await assert.rejects(resolveCallbackDestination('http://192.168.1.1/', { lookup: unresolvable }), { code: 'ERR_UNSAFE_CALLBACK_URL' }); await assert.rejects(resolveCallbackDestination('http://localhost/', { lookup: unresolvable }), { code: 'ERR_UNSAFE_CALLBACK_URL' }); }); await withEnv({ HTTPS_PROXY: 'http://tinyproxy:8888', HTTP_PROXY: 'http://tinyproxy:8888' }, async () => { // A bot on the server's own network, with private callbacks allowed, is // still reached directly at the checked address, as before. const local = async () => [{ address: '172.18.0.9', family: 4 }]; const dest = await resolveCallbackDestination('http://mybot:5000/hook', { lookup: local, allowPrivateCallbacks: true }); assert.equal(dest.proxy, undefined); assert.equal(dest.address, '172.18.0.9'); }); await withEnv({ HTTPS_PROXY: 'http://tinyproxy:8888', NO_PROXY: '.mcpc.cloud' }, async () => { const lookup = async () => [{ address: '93.184.216.34', family: 4 }]; const dest = await resolveCallbackDestination('https://haven.mcpc.cloud/', { lookup }); assert.equal(dest.proxy, undefined); assert.equal(dest.address, '93.184.216.34'); }); }); test('link previews (safeGet) go through the proxy when one is set', async () => { const site = await listen(http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(''); })); const proxy = await startProxy(); const target = `http://preview.test:${site.address().port}/page`; try { // preview.test only resolves at the proxy, so this needs the opt-in. await withEnv({ HTTP_PROXY: proxy.url, OUTBOUND_PROXY_RESOLVES_NAMES: 'true' }, async () => { const got = await safeGet(target, { timeoutMs: 5000 }); assert.equal(got.status, 200); assert.match(got.body.toString(), /Proxied/); }); assert.deepEqual(proxy.seen.map((s) => s.target), [target]); } finally { await close(proxy.server); await close(site); } }); test('a pinned http request names the checked IP to the proxy and keeps the Host name', async () => { let hostHeader = null; const site = await listen(http.createServer((req, res) => { hostHeader = req.headers.host; res.end('pinned'); })); const proxy = await startProxy(); const port = site.address().port; try { const body = await new Promise((resolve, reject) => { const req = outboundProxy.request(`http://pinned.example:${port}/p`, { method: 'GET' }, (res) => { let data = ''; res.on('data', (c) => { data += c; }); res.on('end', () => resolve(data)); }, new URL(proxy.url), { address: '127.0.0.1', family: 4 }); req.on('error', reject); req.end(); }); assert.equal(body, 'pinned'); assert.deepEqual(proxy.seen.map((s) => s.target), [`http://127.0.0.1:${port}/p`]); assert.equal(hostHeader, `pinned.example:${port}`); } finally { await close(proxy.server); await close(site); } }); test('a pinned https request tunnels to the checked IP and verifies the certificate for the name', async (t) => { const pair = selfSignedCert(); if (!pair) return t.skip('openssl not available'); const site = await listen(https.createServer(pair, (req, res) => res.end(`tls ${req.headers.host}`))); const proxy = await startProxy(); const port = site.address().port; try { const body = await new Promise((resolve, reject) => { const req = outboundProxy.request(`https://localhost:${port}/`, { method: 'GET', ca: pair.cert }, (res) => { let data = ''; res.on('data', (c) => { data += c; }); res.on('end', () => resolve(data)); }, new URL(proxy.url), { address: '127.0.0.1', family: 4 }); req.on('error', reject); req.end(); }); assert.equal(body, `tls localhost:${port}`); assert.deepEqual(proxy.seen.map((s) => `${s.method} ${s.target}`), [`CONNECT 127.0.0.1:${port}`]); } finally { await close(proxy.server); await close(site); } });