@appsemble/node-utils
Version:
NodeJS utilities used by Appsemble internally.
88 lines • 3.58 kB
JavaScript
import dns from 'node:dns';
import { isIP } from 'node:net';
import ipaddr from 'ipaddr.js';
import { RequestFilteringHttpAgent, RequestFilteringHttpsAgent } from 'request-filtering-agent';
/**
* Detect NAT64 (RFC 6052 well-known prefix) and IPv4-mapped IPv6 addresses whose embedded IPv4 is a
* public unicast address.
*
* `request-filtering-agent` blocks these IPv6 forms, so when they wrap a legitimate public address
* we return it to be added to the allow-list. Addresses embedding a private/loopback/link-local
* IPv4 (e.g. `64:ff9b::127.0.0.1`) return `undefined` and stay blocked.
*
* @param address An IP address string (not a hostname).
* @returns The address when it should be allow-listed, otherwise `undefined`.
*/
function getAllowableEmbeddedAddress(address) {
let parsed;
try {
parsed = ipaddr.parse(address);
}
catch {
return undefined;
}
if (parsed.kind() !== 'ipv6') {
return undefined;
}
const ipv6 = parsed;
const range = ipv6.range();
let ipv4;
if (range === 'rfc6052') {
const parts = ipv6.toByteArray();
// For the well-known prefix /96, the IPv4 is in parts[12...15]
if (parts[0] === 0x00 && parts[1] === 0x64 && parts[2] === 0xff && parts[3] === 0x9b) {
ipv4 = new ipaddr.IPv4(parts.slice(12));
}
}
else if (range === 'ipv4Mapped') {
ipv4 = ipv6.toIPv4Address();
}
return ipv4 && ipv4.range() === 'unicast' ? address : undefined;
}
export async function getSSRFProtectedAgents(options = {}) {
const { allowPrivateIPAddress = process.env.VITEST_CONF_ALLOW_PRIVATE_IP_PROXY === '1', hostname, } = options;
const allowIPAddressList = process.env.SSRF_ALLOW_IP_ADDRESS_LIST?.split(',') ?? [];
// `URL.hostname` wraps IPv6 literals in brackets (e.g. `[64:ff9b::1]`); strip them so the value
// can be parsed as an IP and resolved by DNS.
const normalizedHostname = hostname?.replace(/^\[|]$/g, '');
if (normalizedHostname) {
if (isIP(normalizedHostname)) {
// IP literal: inspect it directly. No DNS lookup is needed (which also avoids resolving on top
// of the lookup request-filtering-agent already performs at connect time).
const allowable = getAllowableEmbeddedAddress(normalizedHostname);
if (allowable) {
allowIPAddressList.push(allowable);
}
}
else {
// Real hostname: resolve to detect NAT64/DNS64-synthesised addresses.
try {
const addresses = await dns.promises.lookup(normalizedHostname, { all: true });
for (const { address } of addresses) {
const allowable = getAllowableEmbeddedAddress(address);
if (allowable) {
allowIPAddressList.push(allowable);
}
}
}
catch {
// Ignore DNS lookup errors, the agent will handle them
}
}
}
const existingHttpOptions = options.httpAgent?.options ?? {};
const existingHttpsOptions = options.httpsAgent?.options ?? {};
return {
httpAgent: new RequestFilteringHttpAgent({
...existingHttpOptions,
allowPrivateIPAddress,
allowIPAddressList,
}),
httpsAgent: new RequestFilteringHttpsAgent({
...existingHttpsOptions,
allowPrivateIPAddress,
allowIPAddressList,
}),
};
}
//# sourceMappingURL=ssrf.js.map