From 497277ef7b48c86aa2f44b613e0a35a0c3b0c238 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 15 Jun 2026 23:35:50 -0700 Subject: lnurl: SSRF-guard user-supplied URL fetches + validate zap events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- routes/lnurl.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) (limited to 'routes') diff --git a/routes/lnurl.ts b/routes/lnurl.ts index 2c7c2158..c96a9160 100644 --- a/routes/lnurl.ts +++ b/routes/lnurl.ts @@ -4,7 +4,8 @@ import { err, warn } from "$lib/logging"; import { serverPubkey2 } from "$lib/nostr"; import { SATS, bail, fail, getInvoice, getUser } from "$lib/utils"; import { bech32 } from "bech32"; -import got from "got"; +import { safeGot } from "$lib/safe-fetch"; +import { verifyEvent } from "nostr-tools"; import { v4 } from "uuid"; import { PaymentType } from "$lib/types"; @@ -24,7 +25,7 @@ export default { .replace(/\s/g, "")}`; try { - const r = await got(url).json(); + const r = await safeGot(url); if (r.tag !== "payRequest") fail("not an ln address"); } catch (e) { const m = `failed to lookup lightning address ${address}`; @@ -45,7 +46,7 @@ export default { bech32.fromWords(bech32.decode(text, 20000).words), ).toString(); - const r = await got(url).json(); + const r = await safeGot(url); res.send(r); } catch (e) { bail(res, e.message); @@ -116,7 +117,10 @@ export default { if (nostr) { try { const event = JSON.parse(decodeURIComponent(nostr)); - // TODO: validate the event + // NIP-57: must be a signed kind-9734 zap request. Reject anything + // else so we can't be tricked into storing a forged zap receipt. + if (event.kind !== 9734 || !verifyEvent(event)) + throw new Error("invalid zap request"); await s(`zap:${id}`, event); metadata = nostr; } catch (e) { @@ -154,7 +158,7 @@ export default { const { url } = req.query; if (!url) return bail(res, "url required"); try { - const r = await got(url).json(); + const r = await safeGot(url); res.send(r); } catch (e) { warn("lnurl proxy failed", url, e.message); -- cgit v1.2.3