/** * Coolify webhook signature verification. * * Coolify (≥ 4.0.0-beta.300) signs every webhook with HMAC-SHA256 of the * raw body using the per-app `webhook_secret`. The signature is sent in * the `X-Coolify-Signature-256` header as `sha256=`. * * If the per-app secret is not set, Coolify sends the body unsigned. In * that case we reject the call: every prod deploy MUST set a secret. * * Mirrors the pattern in `lib/gitea.ts:verifyWebhookSignature`. */ import { timingSafeStringEq } from "@/lib/server/timing-safe"; export async function verifyCoolifySignature( body: string, signatureHeader: string | null, secret: string, ): Promise { if (!secret) return false; if (!signatureHeader?.startsWith("sha256=")) return false; const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( "raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const sigBytes = await crypto.subtle.sign("HMAC", key, encoder.encode(body)); const expected = "sha256=" + Array.from(new Uint8Array(sigBytes)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); return timingSafeStringEq(expected, signatureHeader); }