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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user