/** * @module @arcis/node/validation/url-async * * Async SSRF guard that closes the DNS-rebinding TOCTOU gap left open * by the synchronous `validateUrl` (sdk-vectors.md #31, issue #50). * * The synchronous `validateUrl` only checks the *string form* of the * hostname. It catches obvious cases — `127.0.0.1`, `10.0.0.1`, * `169.254.169.254` — but a hostname like `evil.com` passes through * even when its DNS A-record points to `10.0.0.1`, because resolution * happens later inside `fetch`. An attacker controlling the * `evil.com` zone can also rebind the answer between Arcis's check * and the actual TCP connect, which is the classic DNS TOCTOU. * * Two layers of fix shipped here: * * 1. **`validateUrlAsync(url, options)`** — runs the existing sync * `validateUrl` first, then `dns.lookup(hostname, { all: true })`, * then re-runs the same private-range check on every resolved * address. Returns the pinned IP list so callers can reuse it for * the actual connection (closing the TOCTOU window). * * 2. **`pinnedDnsLookup(ip)`** — returns a Node `lookup` callback * that resolves any hostname to the pre-validated IP. Wire this * into `https.request({ lookup })` / `http.request({ lookup })` * so the connection uses the IP Arcis already validated, not * whatever DNS returns at connect time. Pure stdlib — no undici, * no extra dep. * * 3. **`safeFollowRedirect(prev, location, options)`** — when the * server replies 30x, run the same async guard against the new * Location URL. Resolves the absolute URL using the previous * response URL as base. Caller decides whether to follow. * * The function signatures keep `lookup` injectable so tests can * substitute a fake resolver without monkey-patching `node:dns`. * * ```ts * import https from 'node:https'; * import { validateUrlAsync, pinnedDnsLookup } from '@arcis/node'; * * const result = await validateUrlAsync(url); * if (!result.safe) throw new Error(result.reason); * * https.get(url, { lookup: pinnedDnsLookup(result.resolvedIp!) }, (res) => { * // The TCP connect now goes to result.resolvedIp regardless of * // what DNS would say at this exact moment. * }); * ``` */ import { type ValidateUrlOptions, type ValidateUrlResult } from './url'; /** * Subset of `dns.lookup`'s `{ all: true }` callback signature. Kept * narrow so a test fake can satisfy it without depending on Node's * full `LookupAddress` type. */ export type LookupAddress = { address: string; family: number; }; /** * Function shape compatible with `dns.lookup(hostname, { all: true })`. * Returns a list of resolved addresses. Tests inject a fake. */ export type DnsLookup = (hostname: string) => Promise; export interface ValidateUrlAsyncOptions extends ValidateUrlOptions { /** * DNS lookup function. Defaults to a Promise wrapper around * `dns.lookup(hostname, { all: true })`. Tests inject a stub. */ lookup?: DnsLookup; /** * If true, accept the first non-private IP and ignore the rest. * Default false: every resolved IP must pass the private-range * check. Hosts with mixed-public/private answers (round-robin DNS * with one internal record) still fail-closed. */ acceptFirstPublic?: boolean; } export interface ValidateUrlAsyncResult extends ValidateUrlResult { /** * Single pinned IP (the first public address if all checks passed, * or undefined when the string-only synchronous validator already * decided — e.g., the hostname *was* a literal IP). Use this with * `pinnedDnsLookup()` to wire the actual fetch. */ resolvedIp?: string; /** Every IP returned by DNS, in resolver order. */ resolvedIps?: string[]; } /** * Async SSRF guard with DNS resolution. Runs the sync validator * first, then resolves DNS and validates every returned IP against * the same private-range rules. Returns a pinned IP for the caller * to reuse. * * Failure modes (any returns `{ safe: false, reason }`): * - Sync validator already rejects (string-pattern fail). * - DNS lookup throws (NXDOMAIN, network error). Reason carries the * underlying error message. * - DNS returns no addresses. * - Any resolved address fails the private-range check (default) or * *all* fail it when `acceptFirstPublic` is true. */ export declare function validateUrlAsync(url: string, options?: ValidateUrlAsyncOptions): Promise; /** * Build a `lookup` callback that pins the resolution to a single * pre-validated IP, regardless of what DNS would say at connect time. * Drop into `https.request({ lookup })` / `http.request({ lookup })`. * * Closes the TOCTOU window between `validateUrlAsync` and the actual * TCP connect. The pinned IP must be one that already passed the * async validator — wiring it without that check defeats the purpose. * * The returned function matches Node's `dns.lookup` callback shape * for the `{ all: false, family: 0 }` case (single address). The * default Node http/https stack uses that shape. */ export declare function pinnedDnsLookup(ip: string): (hostname: string, options: { family?: number; hints?: number; verbatim?: boolean; }, callback: (err: NodeJS.ErrnoException | null, address: string, family: number) => void) => void; /** * Validate a redirect target with the same TOCTOU-aware pipeline. * * `prev` is the URL of the response that returned the 30x; `location` * is the raw `Location:` header value (which may be relative). The * function resolves `location` against `prev` per RFC 3986 then runs * `validateUrlAsync` on the absolute result. * * Use this on every hop of a redirect chain. Without it, a server * that you trust today can redirect tomorrow's request to * `http://169.254.169.254/`. */ export declare function safeFollowRedirect(prev: string, location: string, options?: ValidateUrlAsyncOptions): Promise; //# sourceMappingURL=url-async.d.ts.map