Add security measures to prevent malicious requests when fetching LNURL data

Implement SSRF protection by validating URLs, blocking private IPs, and enforcing HTTPS in the LNURL fetch path.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 35635246-7119-4788-b55f-66a002409788
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/Q83Uqvu
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
alexpaynex
2026-03-19 19:55:59 +00:00
parent 8a81918226
commit 0b3a701933

View File

@@ -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<string | null> {
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({