diff options
| author | Adam Soltys <[email protected]> | 2026-06-15 23:35:50 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-15 23:35:50 -0700 |
| commit | 497277ef7b48c86aa2f44b613e0a35a0c3b0c238 (patch) | |
| tree | 5a05d5f2f1bc739d9bd76f96b5eaeb94f09224fa /lib/safe-fetch.ts | |
| parent | 35df18174ab61c6d4a72bf8f1157584adb14bc4f (diff) | |
| download | coinos-server-497277ef7b48c86aa2f44b613e0a35a0c3b0c238.tar.gz coinos-server-497277ef7b48c86aa2f44b613e0a35a0c3b0c238.zip | |
lnurl: SSRF-guard user-supplied URL fetches + validate zap events
The /lnurl/proxy, /decode and /encode handlers fetched an arbitrary
user-supplied URL via got() and returned the body — an unauthenticated
SSRF reaching internal services. Route those fetches through a new
safeGot() that rejects hosts resolving to loopback/private/link-local/
CGNAT/metadata ranges, re-validates on each redirect hop, restricts the
scheme to http(s), and caps timeout + response size. Also validate the
NIP-57 zap request (kind 9734 + signature) before storing it, instead of
trusting the client-supplied event.
These endpoints can't be removed — they back lightning-address/LNURL
parsing and resolution in the wallet UI.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'lib/safe-fetch.ts')
| -rw-r--r-- | lib/safe-fetch.ts | 88 |
1 files changed, 88 insertions, 0 deletions
diff --git a/lib/safe-fetch.ts b/lib/safe-fetch.ts new file mode 100644 index 00000000..4bc5bbe0 --- /dev/null +++ b/lib/safe-fetch.ts @@ -0,0 +1,88 @@ +// 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"; +import net from "node:net"; +import got from "got"; + +const blockedV4 = (ip: string): boolean => { + const p = ip.split(".").map(Number); + if (p.length !== 4 || p.some((n) => Number.isNaN(n))) return true; + const [a, b] = p; + if (a === 0) return true; // 0.0.0.0/8 + if (a === 10) return true; // private + if (a === 127) return true; // loopback + if (a === 169 && b === 254) return true; // link-local + cloud metadata (169.254.169.254) + if (a === 172 && b >= 16 && b <= 31) return true; // private + if (a === 192 && b === 168) return true; // private + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64/10 (incl. tailscale) + if (a >= 224) return true; // multicast / reserved + return false; +}; + +const isBlockedIp = (ip: string): boolean => { + if (net.isIPv4(ip)) return blockedV4(ip); + if (net.isIPv6(ip)) { + const low = ip.toLowerCase(); + if (low === "::1" || low === "::") return true; // loopback / unspecified + const mapped = low.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/); + if (mapped) return blockedV4(mapped[1]); // IPv4-mapped + if (/^fe[89ab]/.test(low)) return true; // fe80::/10 link-local + const head = low.split(":")[0]; + if (head.startsWith("fc") || head.startsWith("fd")) return true; // fc00::/7 ULA + return false; + } + 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); + } + if (!ips.length) throw new Error(`cannot resolve ${host}`); + for (const ip of ips) + if (isBlockedIp(ip)) throw new Error(`blocked host ${host} -> ${ip}`); +}; + +// 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"); + + await assertHostAllowed(u.hostname); + + 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(); +}; |
