Source: utils/ssrfGuard.js

/**
 * @module utils/ssrfGuard
 * @description Shared SSRF protection utilities.
 *
 * Extracted from `routes/trigger.js` so the same two-layer defence can be
 * reused wherever the server makes outbound HTTP requests to user-configured
 * URLs (notification webhooks, callback URLs, etc.).
 *
 * ### Exports
 * - {@link validateUrl}  — Synchronous string checks + async DNS resolution.
 * - {@link safeFetch}    — Fetch with DNS re-resolution and redirect blocking.
 * - {@link isPrivateIp}  — Check whether an IP is in a private/reserved range.
 */

import { URL } from "url";
import dns from "node:dns";

// ─── Private IP detection ─────────────────────────────────────────────────────

/** @type {Array<Array<number>>} [baseIp, mask, bits] for IPv4 */
const PRIVATE_IPV4_RANGES = [
  // 10.0.0.0/8
  [0x0A000000, 0xFF000000, 8],
  // 172.16.0.0/12
  [0xAC100000, 0xFFF00000, 12],
  // 192.168.0.0/16
  [0xC0A80000, 0xFFFF0000, 16],
  // 127.0.0.0/8 (loopback)
  [0x7F000000, 0xFF000000, 8],
  // 169.254.0.0/16 (link-local / cloud metadata)
  [0xA9FE0000, 0xFFFF0000, 16],
  // 0.0.0.0/8
  [0x00000000, 0xFF000000, 8],
];

function ipv4ToInt(ip) {
  const parts = ip.split(".").map(Number);
  if (parts.length !== 4 || parts.some((p) => isNaN(p) || p < 0 || p > 255)) return null;
  return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
}

/**
 * Check whether an IP address is in a private or reserved range.
 *
 * @param {string} ip - IPv4 or IPv6 address string.
 * @returns {boolean}
 */
export function isPrivateIp(ip) {
  // IPv6 loopback
  if (ip === "::1" || ip === "0:0:0:0:0:0:0:1") return true;

  // Only check IPv6 prefix ranges when the input is actually an IPv6 address
  // (contains a colon).  Without this guard, hostnames like "fdic.gov",
  // "fcbarcelona.com", or "ffmpeg.org" would be falsely rejected because
  // their first characters match IPv6 private-range prefixes.
  if (ip.includes(":")) {
    const lower = ip.toLowerCase();
    // fc00::/7 — unique local addresses (includes fd00::/8)
    if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
    // fe80::/10 — link-local
    if (lower.startsWith("fe80")) return true;
    // ff00::/8 — multicast
    if (lower.startsWith("ff")) return true;
    // :: — unspecified address
    if (ip === "::" || ip === "0:0:0:0:0:0:0:0") return true;
  }

  // IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
  const v4match = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
  const v4 = v4match ? v4match[1] : ip;
  const num = ipv4ToInt(v4);
  if (num === null) return false; // not an IP address — hostname validation is handled by the caller
  for (const [base, mask] of PRIVATE_IPV4_RANGES) {
    if (((num & mask) >>> 0) === base) return true;
  }
  return false;
}

// ─── DNS resolution check (shared by validateUrl and safeFetch) ───────────────

/**
 * Resolve a hostname via DNS and check all addresses for private/reserved IPs.
 *
 * Skips resolution for bare IP addresses (already checked by the caller via
 * `isPrivateIp`). Resolves both A and AAAA records to prevent bypass via a
 * safe A record paired with a private AAAA record.
 *
 * @param {string} host - Lowercase hostname to resolve.
 * @returns {Promise<string|null>} null if safe, or an error message string.
 */
async function resolveAndCheckDns(host) {
  // Skip for bare IP addresses — already checked by isPrivateIp in the caller.
  if (ipv4ToInt(host) !== null || host.includes(":")) return null;

  try {
    const [v4addrs, v6addrs] = await Promise.all([
      dns.promises.resolve4(host).catch(() => []),
      dns.promises.resolve6(host).catch(() => []),
    ]);
    const allAddrs = [...v4addrs, ...v6addrs];
    if (allAddrs.length === 0) {
      return "URL hostname could not be resolved.";
    }
    for (const addr of allAddrs) {
      if (isPrivateIp(addr)) {
        return "URL resolves to a private or reserved IP address.";
      }
    }
  } catch {
    return "URL hostname could not be resolved.";
  }

  return null;
}

// ─── URL validation ───────────────────────────────────────────────────────────

/**
 * Validate a URL for SSRF safety.
 *
 * Performs synchronous string checks (protocol, known private hostnames,
 * literal private IPs) and then resolves the hostname via DNS to catch
 * domains that point to private/reserved addresses.
 *
 * @param {string} raw - The URL to validate.
 * @returns {Promise<string|null>} null if valid, or an error message string.
 */
export async function validateUrl(raw) {
  let parsed;
  try { parsed = new URL(raw); } catch { return "URL is not valid."; }
  if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
    return "URL must use http or https.";
  }
  // Block obvious private hostnames
  const host = parsed.hostname.toLowerCase();
  if (host === "localhost" || host.endsWith(".local") || host.endsWith(".internal")) {
    return "URL must not target a private/internal host.";
  }
  if (isPrivateIp(host)) {
    return "URL must not target a private or reserved IP address.";
  }

  return resolveAndCheckDns(host);
}

// ─── Safe fetch ───────────────────────────────────────────────────────────────

/**
 * Fetch a URL with SSRF protections applied at request time.
 *
 * - Re-resolves DNS to mitigate DNS rebinding attacks.
 * - Blocks redirects (`redirect: "error"`) to prevent open-redirect SSRF bypass.
 *
 * @param {string} url     - The URL to fetch.
 * @param {Object} options - Standard fetch options (method, headers, body, signal, etc.).
 * @returns {Promise<Response>}
 * @throws {Error} If DNS re-resolution detects a private IP or the fetch fails.
 */
export async function safeFetch(url, options = {}) {
  const parsed = new URL(url);
  const host = parsed.hostname.toLowerCase();
  const dnsErr = await resolveAndCheckDns(host);
  if (dnsErr) throw new Error(dnsErr);

  return fetch(url, {
    ...options,
    // Prevent open-redirect bypass: a 302 to http://169.254.169.254/…
    // would bypass hostname validation.
    redirect: "error",
  });
}