← back to Govarbitrage

src/lib/ssrf-guard.ts

105 lines

import { lookup } from "node:dns/promises";

// SSRF guard for the URL importer. Validates that a user-supplied URL points at
// a publicly-routable host before Playwright is pointed at it, blocking cloud
// metadata endpoints (169.254.169.254 / metadata.google.internal), localhost,
// and RFC1918 / loopback / link-local / ULA ranges. Node-runtime only (uses
// node:dns/promises) — do NOT import from Edge middleware.

function ipv4ToInt(ip: string): number | null {
  const parts = ip.split(".");
  if (parts.length !== 4) return null;
  let n = 0;
  for (const p of parts) {
    if (!/^\d+$/.test(p)) return null;
    const o = Number(p);
    if (o < 0 || o > 255) return null;
    n = n * 256 + o;
  }
  return n >>> 0;
}

function isPrivateIPv4(ip: string): boolean {
  const n = ipv4ToInt(ip);
  if (n === null) return false;
  const inRange = (base: string, bits: number) => {
    const baseInt = ipv4ToInt(base)!;
    const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
    return (n & mask) === (baseInt & mask);
  };
  return (
    inRange("10.0.0.0", 8) ||
    inRange("172.16.0.0", 12) ||
    inRange("192.168.0.0", 16) ||
    inRange("127.0.0.0", 8) ||
    inRange("169.254.0.0", 16) ||
    inRange("0.0.0.0", 8)
  );
}

function isPrivateIPv6(addr: string): boolean {
  const ip = addr.toLowerCase().replace(/^\[|\]$/g, "");
  // IPv4-mapped (::ffff:a.b.c.d) — defer to the IPv4 check.
  const mapped = ip.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
  if (mapped) return isPrivateIPv4(mapped[1]);
  if (ip === "::1" || ip === "::") return true; // loopback / unspecified
  const first = ip.split(":")[0];
  const hi = parseInt((first || "0").padStart(4, "0"), 16);
  if ((hi & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local (fe80..febf)
  if ((hi & 0xfe00) === 0xfc00) return true; // fc00::/7 unique-local (fc00..fdff)
  return false;
}

function isBlockedAddress(addr: string): boolean {
  return addr.includes(":") ? isPrivateIPv6(addr) : isPrivateIPv4(addr);
}

/**
 * Parse and validate a user-supplied URL, throwing when it targets a
 * non-public destination. Returns the parsed URL on success.
 */
export async function assertPublicUrl(rawUrl: string): Promise<URL> {
  let url: URL;
  try {
    url = new URL(rawUrl);
  } catch {
    throw new Error("Invalid URL");
  }

  if (url.protocol !== "http:" && url.protocol !== "https:") {
    throw new Error(`Blocked URL scheme: ${url.protocol}`);
  }

  const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
  if (
    host === "localhost" ||
    host.endsWith(".internal") ||
    host === "metadata.google.internal"
  ) {
    throw new Error(`Blocked hostname: ${host}`);
  }

  // A literal IP in the host still needs to pass the range check.
  if (isBlockedAddress(host)) {
    throw new Error(`Blocked private address: ${host}`);
  }

  // Resolve the hostname and reject if ANY address is private/loopback/etc.
  let addresses: { address: string }[];
  try {
    addresses = await lookup(host, { all: true });
  } catch {
    throw new Error(`Could not resolve hostname: ${host}`);
  }
  if (addresses.length === 0) {
    throw new Error(`Could not resolve hostname: ${host}`);
  }
  for (const { address } of addresses) {
    if (isBlockedAddress(address)) {
      throw new Error(`Blocked private address for ${host}: ${address}`);
    }
  }

  return url;
}