From 31264a8233ec4804a9509df88ca99f9abcb71783 Mon Sep 17 00:00:00 2001 From: aiirvizionz Date: Wed, 15 Jul 2026 12:48:20 -0600 Subject: [PATCH] fix(app): require CoinPay webhook signatures --- apps/pwa/src/lib/coinpay-webhook.mjs | 13 +++++++ apps/pwa/src/routes/credits.mjs | 8 ++-- apps/pwa/test/credits-webhook.test.mjs | 51 ++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 apps/pwa/src/lib/coinpay-webhook.mjs create mode 100644 apps/pwa/test/credits-webhook.test.mjs diff --git a/apps/pwa/src/lib/coinpay-webhook.mjs b/apps/pwa/src/lib/coinpay-webhook.mjs new file mode 100644 index 0000000..24ffe59 --- /dev/null +++ b/apps/pwa/src/lib/coinpay-webhook.mjs @@ -0,0 +1,13 @@ +import { config } from "../config.mjs"; +import { verifySignature } from "./signature.mjs"; + +export function verifyCoinPayWebhookRequest(req) { + if (!config.coinpay.webhookSecret) { + return { ok: false, status: 503, error: "coinpay webhook secret not configured" }; + } + const signature = req.get("x-coinpay-signature") || req.get("webhook-signature"); + if (!verifySignature(signature, req.rawBody || "", config.coinpay.webhookSecret)) { + return { ok: false, status: 401, error: "bad signature" }; + } + return { ok: true }; +} diff --git a/apps/pwa/src/routes/credits.mjs b/apps/pwa/src/routes/credits.mjs index 8a912f2..b556c16 100644 --- a/apps/pwa/src/routes/credits.mjs +++ b/apps/pwa/src/routes/credits.mjs @@ -4,7 +4,7 @@ import { get, run } from "../db.mjs"; import { config } from "../config.mjs"; import { id } from "../lib/crypto.mjs"; import { grant } from "../lib/credits.mjs"; -import { verifySignature } from "../lib/signature.mjs"; +import { verifyCoinPayWebhookRequest } from "../lib/coinpay-webhook.mjs"; import { requireAuth } from "../lib/session.mjs"; export const creditsRouter = Router(); @@ -48,9 +48,9 @@ creditsRouter.post("/credits/buy", requireAuth, async (req, res) => { // CoinPay confirms payment → credit the balance (idempotent on purchase id). creditsRouter.post("/webhooks/coinpay", async (req, res) => { - if (config.coinpay.webhookSecret && !verifySignature(req.get("x-coinpay-signature") || req.get("webhook-signature"), req.rawBody || "", config.coinpay.webhookSecret)) { - return res.status(401).json({ error: "bad signature" }); - } + const auth = verifyCoinPayWebhookRequest(req); + if (!auth.ok) return res.status(auth.status).json({ error: auth.error }); + const event = req.body?.type || req.body?.event; const payId = req.body?.data?.id || req.body?.payment_id || req.body?.id; if (event && /confirmed|completed|paid/i.test(event) && payId) { diff --git a/apps/pwa/test/credits-webhook.test.mjs b/apps/pwa/test/credits-webhook.test.mjs new file mode 100644 index 0000000..01144bd --- /dev/null +++ b/apps/pwa/test/credits-webhook.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import test from "node:test"; +import { config } from "../src/config.mjs"; +import { verifyCoinPayWebhookRequest } from "../src/lib/coinpay-webhook.mjs"; + +function reqFor({ signature = "", rawBody = "{}" } = {}) { + return { + rawBody, + get(name) { + return name.toLowerCase() === "x-coinpay-signature" ? signature : ""; + }, + }; +} + +function sign(rawBody, secret, ts = Math.floor(Date.now() / 1000)) { + const v1 = crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex"); + return `t=${ts},v1=${v1}`; +} + +test("CoinPay webhooks fail closed when no secret is configured", () => { + const previous = config.coinpay.webhookSecret; + config.coinpay.webhookSecret = ""; + try { + assert.deepEqual(verifyCoinPayWebhookRequest(reqFor()), { + ok: false, + status: 503, + error: "coinpay webhook secret not configured", + }); + } finally { + config.coinpay.webhookSecret = previous; + } +}); + +test("CoinPay webhooks require a valid HMAC signature", () => { + const previous = config.coinpay.webhookSecret; + config.coinpay.webhookSecret = "test-secret"; + const rawBody = JSON.stringify({ event: "paid", id: "pay_123" }); + try { + assert.deepEqual(verifyCoinPayWebhookRequest(reqFor({ signature: "bad", rawBody })), { + ok: false, + status: 401, + error: "bad signature", + }); + assert.deepEqual(verifyCoinPayWebhookRequest(reqFor({ signature: sign(rawBody, "test-secret"), rawBody })), { + ok: true, + }); + } finally { + config.coinpay.webhookSecret = previous; + } +});