// author: simon-pierre boucher import { lookup } from "node:dns/promises"; import { isIP } from "node:net"; import { err, ok, tendrilError, type Result } from "@tendril/shared"; import { isBlockedIp } from "./ip.js"; const ALLOWED_PORTS = new Set([80, 443, 8080, 8443]); const DEFAULT_PORT: Readonly> = { "http:": 80, "https:": 443 }; export interface SafeTarget { readonly url: URL; readonly host: string; readonly port: number; /** All resolved addresses, all verified public. Pin these to defeat DNS rebinding. */ readonly addresses: readonly string[]; } export type Resolver = (host: string) => Promise; const defaultResolver: Resolver = async (host) => { const results = await lookup(host, { all: true }); return results.map((r) => r.address); }; /** * Validate a URL for egress (ยง16.5). Rejects non-http(s) schemes and disallowed * ports up front, then resolves DNS and rejects if *any* resolved address is * private/loopback/link-local. The returned `addresses` should be pinned by the * caller when connecting, so a rebind between validation and fetch cannot slip * a private IP through. */ export async function validateEgress( raw: string, resolver: Resolver = defaultResolver, ): Promise> { let url: URL; try { url = new URL(raw); } catch { return err(tendrilError("ERR_INVALID_URL", { details: { url: raw } })); } if (url.protocol !== "http:" && url.protocol !== "https:") { return err(tendrilError("ERR_INVALID_URL", { details: { scheme: url.protocol } })); } const port = url.port !== "" ? Number(url.port) : DEFAULT_PORT[url.protocol] ?? 0; if (!ALLOWED_PORTS.has(port)) { return err(tendrilError("ERR_SSRF_BLOCKED", { message: "Port not allowed", details: { port } })); } const host = url.hostname.toLowerCase(); const literal = host.startsWith("[") ? host.slice(1, -1) : host; const hostIsIp = isIP(literal) !== 0; if (hostIsIp && isBlockedIp(literal)) { return err(tendrilError("ERR_SSRF_BLOCKED", { details: { host } })); } let addresses: readonly string[]; if (hostIsIp) { addresses = [literal]; } else { try { addresses = await resolver(literal); } catch (cause) { return err(tendrilError("ERR_INVALID_URL", { message: "DNS resolution failed", details: { host }, cause })); } } if (addresses.length === 0) { return err(tendrilError("ERR_INVALID_URL", { message: "No addresses resolved", details: { host } })); } for (const addr of addresses) { if (isBlockedIp(addr)) { return err(tendrilError("ERR_SSRF_BLOCKED", { details: { host, resolved: addr } })); } } return ok({ url, host, port, addresses }); }