From 900590a8479e802bce0c78605758ea582aa96a66 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 27 Aug 2026 11:19:10 -0700 Subject: lnurl: pin SSRF-guard fetches to the validated IP (close DNS-rebinding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior guard resolved the target host, checked the IP against the private/loopback/link-local/CGNAT/metadata blocklist, then let got fetch the URL — which re-resolves DNS independently when it connects, leaving a rebinding window where a low-TTL host can pass the check as public and connect as internal. Reimplement safeGot on node http/https with a custom lookup that validates and returns the exact IP the socket then connects to, so the validation and the connection target are one and the same (no resolve/connect gap). Redirects are followed manually and every hop is re-validated the same way; IP-literal hosts (which bypass the lookup hook) are checked up front; the 2MB cap is enforced on the streamed body rather than a spoofable content-length header. Scheme stays restricted to http(s) with an 8s timeout. safeGot's contract is unchanged, so the three lnurl callers are untouched. Verified in-container: every internal target (loopback, docker-internal, 169.254 metadata, CGNAT, ULA, IP-literal, octal-literal) is blocked by the guard, a server-side redirect into the metadata endpoint is blocked at the hop, and legit external resolution (including http->https redirects) still works with correct TLS against the real hostname over the pinned IP. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DKkev76U4YRnRAqM6jqbae --- lib/safe-fetch.ts | 143 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 92 insertions(+), 51 deletions(-) (limited to 'lib') diff --git a/lib/safe-fetch.ts b/lib/safe-fetch.ts index 4bc5bbe0..1f9ba2fe 100644 --- a/lib/safe-fetch.ts +++ b/lib/safe-fetch.ts @@ -1,13 +1,21 @@ -// SSRF-resistant wrapper around got for fetching user-supplied URLs (lnurl / -// lightning-address resolution in routes/lnurl.ts). Resolves the target host -// and rejects it if any resolved IP is loopback, private, link-local, CGNAT, -// or cloud-metadata. Re-validates on every redirect hop so a 30x can't bounce -// us into the internal network. (A determined DNS-rebinding attacker still has -// a small TOCTOU window between resolve and connect; acceptable here — the -// dominant risk was unrestricted internal access, which this closes.) -import dns from "node:dns/promises"; +// SSRF-resistant fetch for user-supplied URLs (lnurl / lightning-address +// resolution in routes/lnurl.ts). Rejects any host that resolves to a +// loopback, private, link-local, CGNAT, or cloud-metadata address. +// +// Unlike a resolve-then-fetch guard (which re-resolves DNS when it actually +// connects, leaving a DNS-rebinding TOCTOU window), this pins the socket to the +// exact IP it validated: the connection's DNS lookup IS the validation, in one +// callback, so a low-TTL rebind cannot swap in an internal IP after the check. +// Redirects are followed manually and each hop is re-validated the same way. +// Scheme is restricted to http(s); 8s timeout; 2MB streamed-body cap. +import http from "node:http"; +import https from "node:https"; +import dns from "node:dns"; import net from "node:net"; -import got from "got"; + +const MAX_BYTES = 2_000_000; +const MAX_REDIRECTS = 4; +const TIMEOUT_MS = 8000; const blockedV4 = (ip: string): boolean => { const p = ip.split(".").map(Number); @@ -39,50 +47,83 @@ const isBlockedIp = (ip: string): boolean => { return true; // not a recognizable IP -> block }; -const assertHostAllowed = async (hostname: string) => { - const host = hostname.replace(/^\[|\]$/g, ""); // strip IPv6 literal brackets - let ips: string[]; - if (net.isIP(host)) ips = [host]; - else { - const recs = await dns.lookup(host, { all: true }); - ips = recs.map((r) => r.address); +// A drop-in for the Node dns.lookup used by http(s).request: resolves the host, +// rejects the whole connection if ANY resolved address is blocked (so an +// attacker can't return [public, private] and hope the stack picks the private +// one), and otherwise hands the socket the first allowed address. Because the +// address returned here is the one the socket connects to, there is no +// resolve/connect gap for a rebind to slip through. +const pinnedLookup = ( + hostname: string, + options: any, + cb: (err: Error | null, address?: any, family?: number) => void, +): void => { + // hostname may be an IP literal (redirect to a raw IP) — validate directly. + if (net.isIP(hostname)) { + if (isBlockedIp(hostname)) return cb(new Error(`blocked host ${hostname}`)); + const fam = net.isIPv6(hostname) ? 6 : 4; + return cb(null, options?.all ? [{ address: hostname, family: fam }] : hostname, fam); } - if (!ips.length) throw new Error(`cannot resolve ${host}`); - for (const ip of ips) - if (isBlockedIp(ip)) throw new Error(`blocked host ${host} -> ${ip}`); + dns.lookup(hostname, { all: true }, (err, addrs: any[]) => { + if (err) return cb(err); + if (!addrs?.length) return cb(new Error(`cannot resolve ${hostname}`)); + const bad = addrs.find((a) => isBlockedIp(a.address)); + if (bad) return cb(new Error(`blocked host ${hostname} -> ${bad.address}`)); + const a = addrs[0]; + if (options?.all) cb(null, [{ address: a.address, family: a.family }]); + else cb(null, a.address, a.family); + }); }; -// Fetch a user-supplied URL with SSRF protection and return the parsed JSON -// body. Throws on a bad scheme/URL or a host that resolves into a blocked range. -export const safeGot = async (url: string): Promise => { - let u: URL; - try { - u = new URL(url); - } catch { - throw new Error("invalid url"); - } - if (u.protocol !== "http:" && u.protocol !== "https:") - throw new Error("unsupported scheme"); +const fetchOnce = (url: string, redirectsLeft: number): Promise => + new Promise((resolve, reject) => { + let u: URL; + try { u = new URL(url); } catch { return reject(new Error("invalid url")); } + if (u.protocol !== "http:" && u.protocol !== "https:") + return reject(new Error("unsupported scheme")); - await assertHostAllowed(u.hostname); + // CRITICAL: Node skips the custom `lookup` when the URL host is already an + // IP literal, so pinnedLookup alone would not see a raw-IP SSRF target. + // Validate any literal host up front (strip IPv6 brackets first). + const literal = u.hostname.replace(/^\[|\]$/g, ""); + if (net.isIP(literal) && isBlockedIp(literal)) + return reject(new Error(`blocked host ${literal}`)); - return got(url, { - timeout: { request: 8000 }, - retry: { limit: 0 }, - maxRedirects: 4, - hooks: { - beforeRedirect: [ - async (options: any) => { - await assertHostAllowed(options.url.hostname); - }, - ], - afterResponse: [ - (response: any) => { - const len = Number(response.headers["content-length"] || 0); - if (len > 2_000_000) throw new Error("response too large"); - return response; - }, - ], - }, - }).json(); -}; + const mod = u.protocol === "https:" ? https : http; + const req = mod.request( + url, + { method: "GET", lookup: pinnedLookup as any, headers: { accept: "application/json" } }, + (res) => { + const status = res.statusCode || 0; + + // manual redirect handling — re-validate each hop via pinnedLookup + if (status >= 300 && status < 400 && res.headers.location) { + res.resume(); // drain + if (redirectsLeft <= 0) return reject(new Error("too many redirects")); + let next: string; + try { next = new URL(res.headers.location, url).toString(); } + catch { return reject(new Error("bad redirect target")); } + return resolve(fetchOnce(next, redirectsLeft - 1)); + } + + let len = 0; + const chunks: Buffer[] = []; + res.on("data", (c: Buffer) => { + len += c.length; + if (len > MAX_BYTES) { req.destroy(); reject(new Error("response too large")); return; } + chunks.push(c); + }); + res.on("end", () => { + try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } + catch { reject(new Error("invalid json response")); } + }); + }, + ); + req.setTimeout(TIMEOUT_MS, () => req.destroy(new Error("request timed out"))); + req.on("error", reject); + req.end(); + }); + +// Fetch a user-supplied URL with SSRF protection and return the parsed JSON +// body. Throws on a bad scheme/URL or a host that resolves into a blocked range. +export const safeGot = async (url: string): Promise => fetchOnce(url, MAX_REDIRECTS); -- cgit v1.2.3