import crypto from "crypto"; import { safeFetch } from "@/lib/validate-webhook-url"; import { decrypt } from "@/lib/encrypt"; // Secrets created before encryption-at-rest was added are a bare 64-char hex // string (crypto.randomBytes(32).toString("hex")) with no ":" separators — // encrypt()'s output is always "iv:authTag:ciphertext". Fall back to treating // the value as already-plaintext rather than a hard migration, since this is // only reached with a value this app itself generated (never user input). export function decryptWebhookSecret(stored: string): string { return stored.split(":").length === 3 ? decrypt(stored) : stored; } /** * Signs and POSTs a single webhook payload. Shared by the per-user * (`lib/webhooks.ts`) and admin (`lib/admin-webhooks.ts`) dispatchers so the * signing/delivery mechanics — which are security-relevant — live in exactly * one place instead of two copies that could drift. */ export async function deliverWebhook( url: string, secret: string, event: string, payload: object ): Promise<{ statusCode: number; success: boolean }> { const body = JSON.stringify({ event, payload, timestamp: new Date().toISOString() }); const sig = crypto.createHmac("sha256", decryptWebhookSecret(secret)).update(body).digest("hex"); try { // safeFetch resolves and pins the connection to a single validated IP // (and re-validates + re-pins every redirect hop), so there's no // separate re-resolution for a rebinding attack to exploit. const res = await safeFetch(url, { method: "POST", headers: { "Content-Type": "application/json", "X-Epicure-Signature": `sha256=${sig}`, "X-Epicure-Event": event, }, body, signal: AbortSignal.timeout(10000), }); return { statusCode: res.status, success: res.ok }; } catch { return { statusCode: 0, success: false }; } }