mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-27 09:05:01 +00:00
417 lines
18 KiB
JavaScript
417 lines
18 KiB
JavaScript
'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('<meta property="og:title" content="Proxied">');
|
|
}));
|
|
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);
|
|
}
|
|
});
|