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

    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 got-compatible request options with SSRF protection baked in.
 *
 * Disables redirects to prevent redirect-based SSRF bypasses.
 *
 * @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;

    return options;
}

module.exports = {
    isUrlSafe,
    getSsrfSafeOptions,
};