aboutsummaryrefslogtreecommitdiffstats
path: root/routes
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-15 23:35:50 -0700
committerAdam Soltys <[email protected]>2026-06-15 23:35:50 -0700
commit497277ef7b48c86aa2f44b613e0a35a0c3b0c238 (patch)
tree5a05d5f2f1bc739d9bd76f96b5eaeb94f09224fa /routes
parent35df18174ab61c6d4a72bf8f1157584adb14bc4f (diff)
downloadcoinos-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 'routes')
-rw-r--r--routes/lnurl.ts14
1 files changed, 9 insertions, 5 deletions
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);