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