mirror of
https://github.com/ancsemi/Haven.git
synced 2026-11-08 06:53:20 +00:00
315 lines
12 KiB
JavaScript
315 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
const dns = require('node:dns').promises;
|
|
const http = require('node:http');
|
|
const https = require('node:https');
|
|
const net = require('node:net');
|
|
const outboundProxy = require('./outboundProxy');
|
|
|
|
const alwaysBlocked = new net.BlockList();
|
|
const privateBlocked = new net.BlockList();
|
|
const globalIpv6 = new net.BlockList();
|
|
|
|
// Link-local, reserved, transition, and documentation ranges are never valid
|
|
// callback destinations, even when private callbacks are explicitly enabled.
|
|
for (const [address, prefix] of [
|
|
['0.0.0.0', 8],
|
|
['169.254.0.0', 16],
|
|
['192.0.0.0', 24],
|
|
['192.0.2.0', 24],
|
|
['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]
|
|
]) alwaysBlocked.addSubnet(address, prefix, 'ipv4');
|
|
|
|
// These ranges are available only to self-hosters who explicitly opt in.
|
|
for (const [address, prefix] of [
|
|
['10.0.0.0', 8],
|
|
['100.64.0.0', 10],
|
|
['127.0.0.0', 8],
|
|
['172.16.0.0', 12],
|
|
['192.168.0.0', 16]
|
|
]) privateBlocked.addSubnet(address, prefix, 'ipv4');
|
|
|
|
for (const [address, prefix] of [
|
|
['::', 128],
|
|
['64:ff9b::', 96],
|
|
['64:ff9b:1::', 48],
|
|
['100::', 64],
|
|
['2001::', 32],
|
|
['2001:2::', 48],
|
|
['2001:10::', 28],
|
|
['2001:20::', 28],
|
|
['2001:db8::', 32],
|
|
['2002::', 16],
|
|
['3fff::', 20],
|
|
['fe80::', 10],
|
|
['fec0::', 10],
|
|
['ff00::', 8]
|
|
]) alwaysBlocked.addSubnet(address, prefix, 'ipv6');
|
|
|
|
// Cloud metadata and host agent services that sit outside the link-local
|
|
// range: Alibaba (100.100.100.200), AWS over IPv6 (fd00:ec2::254) and Azure's
|
|
// wire server (168.63.129.16). They hand out instance credentials, so they
|
|
// stay blocked even when private callbacks are allowed.
|
|
alwaysBlocked.addAddress('100.100.100.200', 'ipv4');
|
|
alwaysBlocked.addAddress('168.63.129.16', 'ipv4');
|
|
alwaysBlocked.addAddress('fd00:ec2::254', 'ipv6');
|
|
|
|
privateBlocked.addSubnet('::1', 128, 'ipv6');
|
|
privateBlocked.addSubnet('fc00::', 7, 'ipv6');
|
|
globalIpv6.addSubnet('2000::', 3, 'ipv6');
|
|
|
|
class UnsafeCallbackError extends Error {
|
|
constructor(message) {
|
|
super(message);
|
|
this.name = 'UnsafeCallbackError';
|
|
this.code = 'ERR_UNSAFE_CALLBACK_URL';
|
|
}
|
|
}
|
|
|
|
function parseCallbackUrl(urlString) {
|
|
let url;
|
|
try {
|
|
url = new URL(urlString);
|
|
} catch {
|
|
throw new UnsafeCallbackError('Callback URL is invalid');
|
|
}
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
throw new UnsafeCallbackError('Callback URL must use HTTP or HTTPS');
|
|
}
|
|
if (url.username || url.password) {
|
|
throw new UnsafeCallbackError('Callback URL must not contain credentials');
|
|
}
|
|
return url;
|
|
}
|
|
|
|
function normalizedHostname(url) {
|
|
return url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
|
|
}
|
|
|
|
function mappedIpv4(address) {
|
|
const lower = address.toLowerCase();
|
|
if (!lower.startsWith('::ffff:')) return null;
|
|
const tail = lower.slice(7);
|
|
if (net.isIP(tail) === 4) return tail;
|
|
const groups = tail.split(':');
|
|
if (groups.length !== 2 || groups.some(group => !/^[a-f0-9]{1,4}$/.test(group))) return null;
|
|
const high = parseInt(groups[0], 16);
|
|
const low = parseInt(groups[1], 16);
|
|
return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
|
|
}
|
|
|
|
function isBlockedAddress(address, family, allowPrivateCallbacks = false) {
|
|
const numericFamily = family === 6 || family === 'IPv6' ? 6 : 4;
|
|
if (numericFamily === 6) {
|
|
const embedded = mappedIpv4(address);
|
|
if (embedded) return isBlockedAddress(embedded, 4, allowPrivateCallbacks);
|
|
}
|
|
const type = numericFamily === 6 ? 'ipv6' : 'ipv4';
|
|
if (alwaysBlocked.check(address, type)) return true;
|
|
if (numericFamily === 6) {
|
|
if (globalIpv6.check(address, 'ipv6')) return false;
|
|
return !(allowPrivateCallbacks && privateBlocked.check(address, 'ipv6'));
|
|
}
|
|
return !allowPrivateCallbacks && privateBlocked.check(address, type);
|
|
}
|
|
|
|
function validateCallbackUrl(urlString, allowPrivateCallbacks = false) {
|
|
try {
|
|
const url = parseCallbackUrl(urlString);
|
|
const hostname = normalizedHostname(url);
|
|
if (!allowPrivateCallbacks && (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local') || hostname.endsWith('.internal'))) {
|
|
return false;
|
|
}
|
|
const family = net.isIP(hostname);
|
|
return !family || !isBlockedAddress(hostname, family, allowPrivateCallbacks);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function resolveCallbackDestination(urlString, options = {}) {
|
|
const allowPrivateCallbacks = options.allowPrivateCallbacks === true;
|
|
const lookup = options.lookup || dns.lookup;
|
|
const url = parseCallbackUrl(urlString);
|
|
const hostname = normalizedHostname(url);
|
|
if (!allowPrivateCallbacks && (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local') || hostname.endsWith('.internal'))) {
|
|
throw new UnsafeCallbackError('Callback URL points to a local hostname');
|
|
}
|
|
|
|
const literalFamily = net.isIP(hostname);
|
|
const proxy = (options.proxyFor || outboundProxy.proxyFor)(url);
|
|
if (proxy) return checkProxiedDestination(url, hostname, literalFamily, proxy, lookup, allowPrivateCallbacks);
|
|
const addresses = literalFamily
|
|
? [{ address: hostname, family: literalFamily }]
|
|
: await lookup(hostname, { all: true, verbatim: true });
|
|
if (!Array.isArray(addresses) || addresses.length === 0) {
|
|
throw new UnsafeCallbackError('Callback hostname did not resolve');
|
|
}
|
|
for (const entry of addresses) {
|
|
const family = entry.address ? net.isIP(entry.address) : 0;
|
|
if (!family || isBlockedAddress(entry.address, family, allowPrivateCallbacks)) {
|
|
throw new UnsafeCallbackError('Callback URL resolves to a blocked network');
|
|
}
|
|
}
|
|
const selected = addresses[0];
|
|
return {
|
|
url,
|
|
address: selected.address,
|
|
family: net.isIP(selected.address)
|
|
};
|
|
}
|
|
|
|
// Through a proxy, the name is still looked up and checked here, and the
|
|
// proxy is then sent to the checked address (CONNECT to the IP for https, an
|
|
// absolute URL with the IP for http, the Host header keeping the name), so it
|
|
// cannot resolve the name again and land on an internal address.
|
|
// A name that resolves only to private addresses the host allowed (a bot next
|
|
// to Haven) is on this machine's own network, which a proxy usually cannot
|
|
// reach, so it stays a direct connection to the checked address, as it was
|
|
// before proxy support.
|
|
// A name that does not resolve here at all (a container whose only way out
|
|
// is the proxy) is refused, unless the host sets
|
|
// OUTBOUND_PROXY_RESOLVES_NAMES=true: then the proxy resolves it and the
|
|
// proxy's own rules are all that decide where it may go.
|
|
// The lookup is not cut short (every caller has its own deadline), so a slow
|
|
// answer is still checked instead of being handed to the proxy unchecked.
|
|
function proxyResolvesNames() {
|
|
return process.env.OUTBOUND_PROXY_RESOLVES_NAMES === 'true';
|
|
}
|
|
|
|
async function checkProxiedDestination(url, hostname, literalFamily, proxy, lookup, allowPrivateCallbacks) {
|
|
let addresses = [];
|
|
if (literalFamily) {
|
|
addresses = [{ address: hostname, family: literalFamily }];
|
|
} else {
|
|
try {
|
|
addresses = await lookup(hostname, { all: true, verbatim: true });
|
|
} catch (err) {
|
|
// Not resolving here is the expected answer behind a proxy-only egress;
|
|
// what that means is decided just below.
|
|
addresses = [];
|
|
if (!proxyResolvesNames()) throw new UnsafeCallbackError(`Callback hostname did not resolve (${err?.code || 'lookup failed'})`);
|
|
}
|
|
}
|
|
const usable = (Array.isArray(addresses) ? addresses : []).filter(entry => entry.address && net.isIP(entry.address));
|
|
for (const entry of usable) {
|
|
if (isBlockedAddress(entry.address, net.isIP(entry.address), allowPrivateCallbacks)) {
|
|
throw new UnsafeCallbackError('Callback URL resolves to a blocked network');
|
|
}
|
|
}
|
|
if (!usable.length) {
|
|
if (proxyResolvesNames()) return { url, proxy };
|
|
throw new UnsafeCallbackError('Callback hostname did not resolve');
|
|
}
|
|
const selected = usable[0];
|
|
const family = net.isIP(selected.address);
|
|
if (usable.every(entry => isBlockedAddress(entry.address, net.isIP(entry.address), false))) {
|
|
return { url, address: selected.address, family };
|
|
}
|
|
return { url, proxy, address: selected.address, family, pinned: true };
|
|
}
|
|
|
|
function createPinnedLookup(address, family) {
|
|
return (_hostname, options, callback) => {
|
|
if (options?.all) callback(null, [{ address, family }]);
|
|
else callback(null, address, family);
|
|
};
|
|
}
|
|
|
|
// The checked address a proxied request must go to, or null when the host
|
|
// let the proxy resolve the name (OUTBOUND_PROXY_RESOLVES_NAMES).
|
|
function proxyPin(destination) {
|
|
return destination.pinned ? { address: destination.address, family: destination.family } : null;
|
|
}
|
|
|
|
// An agent for libraries that take one instead of a lookup (web-push): it
|
|
// connects to exactly the address resolveCallbackDestination checked, or
|
|
// through the proxy when the destination goes that way.
|
|
function agentForDestination(destination) {
|
|
if (destination.proxy) return outboundProxy.agentThrough(destination.url, destination.proxy, undefined, proxyPin(destination));
|
|
const Agent = destination.url.protocol === 'https:' ? https.Agent : http.Agent;
|
|
return new Agent({ keepAlive: false, lookup: createPinnedLookup(destination.address, destination.family) });
|
|
}
|
|
|
|
function withDeadline(promise, deadlineAt, message) {
|
|
const remaining = deadlineAt - Date.now();
|
|
if (remaining <= 0) return Promise.reject(new Error(message));
|
|
let timer;
|
|
return Promise.race([
|
|
promise,
|
|
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), remaining); })
|
|
]).finally(() => clearTimeout(timer));
|
|
}
|
|
|
|
function postResolvedCallback(destination, payload, headers, deadlineAt) {
|
|
return new Promise((resolve, reject) => {
|
|
const remaining = deadlineAt - Date.now();
|
|
if (remaining <= 0) return reject(new Error('Webhook callback exceeded its deadline'));
|
|
const transport = destination.url.protocol === 'https:' ? https : http;
|
|
let response;
|
|
let settled = false;
|
|
let timer;
|
|
const finish = (error, result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
if (error) reject(error);
|
|
else resolve(result);
|
|
};
|
|
const requestOptions = {
|
|
method: 'POST',
|
|
headers: { ...headers, 'Content-Length': Buffer.byteLength(payload) }
|
|
};
|
|
const onResponse = incoming => {
|
|
response = incoming;
|
|
incoming.on('error', finish);
|
|
incoming.on('end', () => finish(null, {
|
|
ok: incoming.statusCode >= 200 && incoming.statusCode < 300,
|
|
status: incoming.statusCode
|
|
}));
|
|
incoming.resume();
|
|
};
|
|
const request = destination.proxy
|
|
? outboundProxy.request(destination.url, requestOptions, onResponse, destination.proxy, proxyPin(destination))
|
|
: transport.request(destination.url, {
|
|
...requestOptions,
|
|
agent: false,
|
|
lookup: createPinnedLookup(destination.address, destination.family)
|
|
}, onResponse);
|
|
timer = setTimeout(() => {
|
|
const error = new Error('Webhook callback exceeded its deadline');
|
|
response?.destroy(error);
|
|
request.destroy(error);
|
|
}, remaining);
|
|
request.on('error', finish);
|
|
request.end(payload);
|
|
});
|
|
}
|
|
|
|
async function postWebhookCallback(urlString, payload, headers, options = {}) {
|
|
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 10000;
|
|
const deadlineAt = Date.now() + timeoutMs;
|
|
const destination = await withDeadline(
|
|
resolveCallbackDestination(urlString, options),
|
|
deadlineAt,
|
|
'Webhook callback DNS resolution timed out'
|
|
);
|
|
return postResolvedCallback(destination, payload, headers, deadlineAt);
|
|
}
|
|
|
|
module.exports = {
|
|
UnsafeCallbackError,
|
|
agentForDestination,
|
|
createPinnedLookup,
|
|
isBlockedAddress,
|
|
postWebhookCallback,
|
|
proxyPin,
|
|
resolveCallbackDestination,
|
|
validateCallbackUrl,
|
|
withDeadline
|
|
};
|