Files
Haven/src/webhookCallback.js
T

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
};