aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-27 11:19:10 -0700
committerAdam Soltys <[email protected]>2026-08-27 11:19:10 -0700
commit900590a8479e802bce0c78605758ea582aa96a66 (patch)
tree4d9d4bab5e6783adb914687835a1498794aa134b /lib
parent01636561aa58b031ac01bdc9a5779ace35040389 (diff)
downloadcoinos-server-900590a8479e802bce0c78605758ea582aa96a66.tar.gz
coinos-server-900590a8479e802bce0c78605758ea582aa96a66.zip
lnurl: pin SSRF-guard fetches to the validated IP (close DNS-rebinding)
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01DKkev76U4YRnRAqM6jqbae
Diffstat (limited to 'lib')
-rw-r--r--lib/safe-fetch.ts143
1 files changed, 92 insertions, 51 deletions
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<any> => {
- 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<any> =>
+ 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<any> => fetchOnce(url, MAX_REDIRECTS);