diff --git a/artifacts/api-server/src/lib/zap.ts b/artifacts/api-server/src/lib/zap.ts index 2a01cdd..e341680 100644 --- a/artifacts/api-server/src/lib/zap.ts +++ b/artifacts/api-server/src/lib/zap.ts @@ -20,6 +20,7 @@ */ import { randomUUID } from "crypto"; +import { promises as dns } from "dns"; import { db, timmyNostrEvents, nostrIdentities } from "@workspace/db"; import { eq } from "drizzle-orm"; import { timmyIdentityService } from "./timmy-identity.js"; @@ -40,6 +41,101 @@ const RELAY_URL = process.env["NOSTR_RELAY_URL"] ?? ""; // LNURL fetch timeout (ms) const LNURL_TIMEOUT_MS = 8_000; +// ── SSRF protection ─────────────────────────────────────────────────────────── +// All LNURL fetches use user-supplied domain names; we must block requests to +// private/loopback/link-local addresses to prevent Server-Side Request Forgery. + +const PRIVATE_RANGES: Array<{ prefix: string; bits: number }> = [ + // IPv4 private + special ranges + { prefix: "10.", bits: 8 }, + { prefix: "172.16.", bits: 12 }, + { prefix: "172.17.", bits: 12 }, + { prefix: "172.18.", bits: 12 }, + { prefix: "172.19.", bits: 12 }, + { prefix: "172.20.", bits: 12 }, + { prefix: "172.21.", bits: 12 }, + { prefix: "172.22.", bits: 12 }, + { prefix: "172.23.", bits: 12 }, + { prefix: "172.24.", bits: 12 }, + { prefix: "172.25.", bits: 12 }, + { prefix: "172.26.", bits: 12 }, + { prefix: "172.27.", bits: 12 }, + { prefix: "172.28.", bits: 12 }, + { prefix: "172.29.", bits: 12 }, + { prefix: "172.30.", bits: 12 }, + { prefix: "172.31.", bits: 12 }, + { prefix: "192.168.", bits: 16 }, + { prefix: "127.", bits: 8 }, // loopback + { prefix: "169.254.", bits: 16 }, // link-local + { prefix: "100.64.", bits: 10 }, // shared address space (RFC 6598) + { prefix: "0.", bits: 8 }, // "this" network + { prefix: "198.18.", bits: 15 }, // benchmarking (RFC 2544) + { prefix: "198.51.100.", bits: 24 }, // TEST-NET-2 + { prefix: "203.0.113.", bits: 24 }, // TEST-NET-3 + { prefix: "240.", bits: 4 }, // reserved +]; + +const BLOCKED_HOSTNAMES = new Set([ + "localhost", "ip6-localhost", "ip6-loopback", "broadcasthost", +]); + +/** Returns true if the resolved IPv4/IPv6 string is in a private/special range. */ +function isPrivateIp(ip: string): boolean { + // IPv6 loopback or link-local + if (ip === "::1" || ip.startsWith("fc") || ip.startsWith("fd") || ip.startsWith("fe80")) { + return true; + } + // IPv4 mapped in IPv6 (::ffff:a.b.c.d) + const v4mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i); + const ipv4 = v4mapped ? v4mapped[1]! : ip; + return PRIVATE_RANGES.some(r => ipv4.startsWith(r.prefix)); +} + +/** + * Validate that a URL is safe to fetch: HTTPS only, not a private/loopback host. + * Resolves the hostname via DNS and checks all returned IPs. + * Returns an error string if blocked, null if safe. + */ +async function assertSafeUrl(urlStr: string): Promise { + let parsed: URL; + try { + parsed = new URL(urlStr); + } catch { + return `Invalid URL: ${urlStr}`; + } + + if (parsed.protocol !== "https:") { + return `LNURL fetch must use HTTPS (got ${parsed.protocol})`; + } + + const host = parsed.hostname.toLowerCase(); + + if (BLOCKED_HOSTNAMES.has(host)) { + return `Blocked hostname: ${host}`; + } + + // If hostname is a literal IP, check it directly + if (/^[\d.]+$/.test(host) || host.startsWith("[")) { + const bare = host.replace(/^\[/, "").replace(/\]$/, ""); + if (isPrivateIp(bare)) { + return `SSRF blocked: IP ${bare} is in a private/reserved range`; + } + return null; + } + + // Resolve hostname via DNS and check all returned addresses + try { + const { address } = await dns.lookup(host, { all: false }); + if (isPrivateIp(address)) { + return `SSRF blocked: ${host} resolved to private IP ${address}`; + } + } catch (err) { + return `DNS resolution failed for ${host}: ${err instanceof Error ? err.message : String(err)}`; + } + + return null; // safe +} + export interface ZapRequest { recipientPubkey: string; amountSats: number; @@ -84,6 +180,13 @@ async function resolveLnurlInvoice( return null; } + // SSRF guard: validate metadata URL before fetching + const metaSafe = await assertSafeUrl(metaUrl); + if (metaSafe) { + logger.warn("zap: SSRF block on LNURL metadata URL", { metaUrl, reason: metaSafe }); + return null; + } + let callbackUrl: string; let minSendable: number; let maxSendable: number; @@ -123,6 +226,14 @@ async function resolveLnurlInvoice( return null; } + // SSRF guard: validate callback URL returned by LNURL server before using it + // This prevents a malicious LNURL server from redirecting Timmy to internal hosts. + const callbackSafe = await assertSafeUrl(callbackUrl); + if (callbackSafe) { + logger.warn("zap: SSRF block on LNURL callback URL", { callbackUrl, reason: callbackSafe }); + return null; + } + // Step 2 — call LNURL-pay callback with zap request try { const params = new URLSearchParams({