/**
* @module api/utils/ssrf-protection
* @description SSRF (Server-Side Request Forgery) protection utilities
* shared by the core API (validateRedirect for app.redirect_url) and
* the Hooks plugin's HTTPEffect.
*
* Provides IP blocklist checking, DNS-level validation, and URL safety
* verification to prevent requests to internal/private network addresses,
* cloud metadata endpoints, and other dangerous targets.
*
* Uses ipaddr.js for robust IP address parsing and range classification,
* covering all RFC-defined private, reserved, loopback, link-local,
* multicast, carrier-grade NAT, and documentation ranges for both
* IPv4 and IPv6 (including IPv4-mapped IPv6 and NAT64).
*
* - URL parse time validation with DNS (isUrlSafe)
* - Protocol restriction (only http/https)
* - Redirects disabled (followRedirect: false via getSsrfSafeOptions)
* - Revalidation after template expansion (when doing request in http effect)
*/
'use strict';
const dns = require('dns');
const net = require('net');
const { URL } = require('url');
const ipaddr = require('ipaddr.js');
/**
* Allowed URL protocols for outbound requests.
*/
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
/**
* Hostnames known to serve cloud metadata / internal services.
* Checked as exact match (case-insensitive).
*/
const BLOCKED_HOSTNAMES = new Set([
'metadata.google.internal',
'metadata.goog',
'metadata.google.com',
'kubernetes.default.svc',
'kubernetes.default',
'kubernetes',
]);
/**
* Check whether an IP address (v4 or v6) is private/reserved/internal.
*
* Uses ipaddr.js range() classification. Only 'unicast' addresses are
* considered safe. All other ranges are blocked:
* - unspecified (0.0.0.0/8, ::)
* - loopback (127.0.0.0/8, ::1)
* - private (10/8, 172.16/12, 192.168/16)
* - linkLocal (169.254/16, fe80::/10)
* - multicast (224/4, ff00::/8)
* - broadcast (255.255.255.255)
* - reserved (192.0.0/24, 192.0.2/24, 192.88.99/24, 198.18/15,
* 198.51.100/24, 203.0.113/24, 240/4, 2001:db8::/32)
* - carrierGradeNat (100.64/10)
* - uniqueLocal (fc00::/7)
* - ipv4Mapped (::ffff:0:0/96 — unwrapped and re-checked as IPv4)
* - rfc6052 (64:ff9b::/96 NAT64)
* - discard (100::/64)
*
* @param {string} ip - IP address string
* @returns {boolean} true if the IP should be blocked
*/
function isBlockedIP(ip) {
let parsed;
try {
parsed = ipaddr.parse(ip);
}
catch (e) {
// Unparseable IP — block to be safe
return true;
}
const range = parsed.range();
// IPv4-mapped IPv6 (::ffff:x.x.x.x): unwrap and check the inner IPv4
if (range === 'ipv4Mapped' && parsed.isIPv4MappedAddress()) {
return parsed.toIPv4Address().range() !== 'unicast';
}
// ipaddr.js reports the well-known NAT64 prefix (64:ff9b::/96) as 'rfc6052'
// (blocked by the unicast check below), but the RFC 8215 local-use NAT64 prefix
// (64:ff9b:1::/48) as generic unicast. Block it explicitly so a NAT64 gateway
// cannot translate its embedded IPv4 into an internal address. Network-specific
// NAT64 prefixes carved from an operator's own unicast space cannot be told
// apart by prefix and remain out of scope.
if (parsed.kind() === 'ipv6' && parsed.match(ipaddr.parseCIDR('64:ff9b:1::/48'))) {
return true;
}
return range !== 'unicast';
}
/**
* Check whether a hostname is a known dangerous internal service.
*
* @param {string} hostname - The hostname to check (will be lowercased)
* @returns {boolean} true if the hostname should be blocked
*/
function isBlockedHostname(hostname) {
const lower = hostname.toLowerCase();
// Exact match against known dangerous hosts
if (BLOCKED_HOSTNAMES.has(lower)) {
return true;
}
// Block "localhost" and variants
if (lower === 'localhost' || lower.endsWith('.localhost')) {
return true;
}
// Block .internal TLD (used by GCP metadata and internal services)
if (lower.endsWith('.internal')) {
return true;
}
return false;
}
/**
* Strip IPv6 brackets from a hostname if present.
* new URL('http://[::1]/').hostname returns '[::1]' with brackets,
* but net.isIP() and our IP checkers expect '::1' without brackets.
*
* @param {string} hostname - hostname possibly wrapped in brackets
* @returns {string} hostname with brackets stripped if it was a bracketed IPv6
*/
function stripIPv6Brackets(hostname) {
if (hostname.startsWith('[') && hostname.endsWith(']')) {
return hostname.slice(1, -1);
}
return hostname;
}
/**
* Validate a URL string for SSRF safety.
*
* @param {string} urlString - The URL to validate
*/
async function isUrlSafe(urlString) {
// Must be a non-empty string
if (!urlString || typeof urlString !== 'string') {
return { safe: false, error: 'URL must be a non-empty string' };
}
// Parse the URL
let parsed;
try {
parsed = new URL(urlString);
}
catch (e) {
return { safe: false, error: 'Invalid URL: ' + e.message };
}
// Protocol restriction
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
return { safe: false, error: `Protocol "${parsed.protocol}" is not allowed. Only http and https are permitted.` };
}
// Block credentials in URL (user:pass@host)
if (parsed.username || parsed.password) {
return { safe: false, error: 'URLs with embedded credentials are not allowed' };
}
const hostname = parsed.hostname;
// Check against blocked hostnames
if (isBlockedHostname(hostname)) {
return { safe: false, error: `Hostname "${hostname}" is blocked` };
}
// Strip IPv6 brackets for IP checks (URL parser returns [::1], net.isIP expects ::1)
const bareHostname = stripIPv6Brackets(hostname);
// If hostname is an IP literal, check it directly
if (net.isIP(bareHostname)) {
if (isBlockedIP(bareHostname)) {
return { safe: false, error: `IP address "${bareHostname}" is in a private/reserved range` };
}
// IP is public — OK at parse time
return { safe: true, error: null };
}
// Hostname is a domain name — resolve it to check the IP
try {
const address = await new Promise((resolve, reject) => {
dns.lookup(hostname, (err, addr) => {
if (err) {
reject(err);
}
else {
resolve(addr);
}
});
});
if (isBlockedIP(address)) {
return { safe: false, error: `Hostname "${hostname}" resolves to private/reserved IP "${address}"` };
}
}
catch (e) {
return { safe: false, error: `DNS resolution failed for "${hostname}": ${e.message}` };
}
return { safe: true, error: null };
}
/**
* Build a blocked-target error for the connect-time lookup guard.
* @param {string} hostname - hostname being resolved
* @param {string} address - resolved IP that was blocked
* @returns {Error} error with an identifiable code
*/
function blockedLookupError(hostname, address) {
const err = new Error(`Blocked SSRF target: "${hostname}" resolved to private/reserved IP "${address}"`);
err.code = 'ESSRFBLOCKED';
return err;
}
/**
* A dns.lookup-compatible function that resolves a hostname and then rejects
* the lookup if the resolved address is private/reserved/internal. Passing this
* as the `lookup` option of an HTTP request (got, node http/https) validates the
* IP AT CONNECT TIME, which closes the DNS-rebinding (TOCTOU) gap left by a
* parse-time-only check: even if a name resolves to a safe IP during isUrlSafe
* and to an internal IP a moment later, the socket only ever connects to an
* address that passed isBlockedIP here.
*
* @param {string} hostname - hostname to resolve
* @param {object|function} options - dns.lookup options, or the callback
* @param {function} [callback] - callback(err, address, family); when
* options.all is true it is called as callback(err, addresses) where
* addresses is an array of {address, family} objects
* @returns {void}
*/
function safeLookup(hostname, options, callback) {
if (typeof options === 'function') {
callback = options;
options = {};
}
options = options || {};
dns.lookup(hostname, options, function(err, address, family) {
if (err) {
return callback(err);
}
// options.all === true -> address is an array of {address, family}
if (options.all) {
const list = Array.isArray(address) ? address : [address];
for (let i = 0; i < list.length; i++) {
if (isBlockedIP(list[i].address)) {
return callback(blockedLookupError(hostname, list[i].address));
}
}
return callback(null, list);
}
if (isBlockedIP(address)) {
return callback(blockedLookupError(hostname, address));
}
callback(null, address, family);
});
}
/**
* Build got-compatible request options with SSRF protection baked in.
*
* Disables redirects to prevent redirect-based SSRF bypasses, and pins DNS
* resolution through safeLookup so the connected IP is validated at connect
* time (DNS-rebinding protection). Callers that issue requests via raw node
* http/https can pass `lookup: safeLookup` directly.
*
* @param {object} requestOptions - base request options (uri, timeout, headers, etc.)
* @returns {object} the same options object with SSRF settings injected
*/
function getSsrfSafeOptions(requestOptions) {
const options = Object.assign({}, requestOptions);
// Disable redirects entirely — prevents redirect-based SSRF bypasses
options.followRedirect = false;
// Validate the resolved IP at connect time (DNS-rebinding protection)
options.lookup = safeLookup;
return options;
}
module.exports = {
isUrlSafe,
getSsrfSafeOptions,
safeLookup,
};