utils/ssrf-protection.js

/**
 * @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,
};