A Razorpay webhook handler. No signature verification. Anyone who knows the URL can POST a fake `payment.captured` event and mark orders paid without paying. Mesrai's catch.
The vulnerable diff
// webhooks/razorpay.ts
razorpayRouter.post("/webhook", async (req, res) => {
// BUG: no signature check. Anyone can POST a payment.captured event.
const event = req.body;
if (event.event === "payment.captured") {
await markOrderPaid(event.payload.payment.entity.order_id);
}
res.json({ ok: true });
});What is wrong
Webhook providers sign requests with HMAC over the request body using a shared secret. The handler must verify the signature on every request — otherwise an attacker who knows the URL (which usually isn't a secret) can forge events. The provider documentation always includes the verification code; the developer has to remember to use it. CWE-345 covers the broader class of insufficient verification of data authenticity.
The attack
Attack:
curl -X POST https://api.example.com/webhooks/razorpay \
-d '{
"event":"payment.captured",
"payload":{"payment":{"entity":{"order_id":"order_attacker"}}}
}'
# Mark any order as paid. No verification.Same shape works for every webhook provider — Stripe, Shopify, GitHub, Twilio, etc.
Mesrai's review comment
mesraipilot · Bot · reviewed 1 min ago
[mesrai] [code-review] [Security] [CWE-345] [critical]
Webhook endpoint accepts any POST without verifying the signature.
Attacker can forge events and trigger arbitrary state changes.
Verify the HMAC signature on every request using the shared secret:
import crypto from "crypto";
razorpayRouter.post("/webhook",
express.raw({ type: "application/json" }), // raw body required
async (req, res) => {
const sig = req.header("x-razorpay-signature") ?? "";
const expected = crypto
.createHmac("sha256", process.env.RZP_WEBHOOK_SECRET!)
.update(req.body)
.digest("hex");
if (
sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString());
// ... process event
res.status(200).end();
}
);
Two details: use the RAW body (not parsed JSON) for HMAC, and use
`timingSafeEqual` to prevent timing-attack leaks.
Reference: Razorpay Webhook Signature Verification docs, CWE-345The fix
// webhooks/razorpay.ts — fixed
import crypto from "crypto";
import express from "express";
razorpayRouter.post(
"/webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
const sig = req.header("x-razorpay-signature") ?? "";
const expected = crypto
.createHmac("sha256", process.env.RZP_WEBHOOK_SECRET!)
.update(req.body)
.digest("hex");
if (
sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString());
if (event.event === "payment.captured") {
await markOrderPaid(event.payload.payment.entity.order_id);
}
res.status(200).end();
}
);Raw body for HMAC. Timing-safe equality. Reject 401 on mismatch. Compose with the idempotency check from cluster 3 post 30 — both are required for production-grade webhook handling.
Why human review missed it
Webhook signature checks are mandatory but often forgotten. The pattern is provider-specific (header name, hash algorithm, body shape) and the documentation lives in the provider's site, not the framework. Mesrai catches every webhook handler that doesn't verify a signature.
Related rules + further reading
Mesrai rule pack: security/webhook-signature-required — flags webhook endpoints without HMAC verification.
Razorpay / Stripe / Shopify / GitHub docs: all document signature verification.
CWE-345 — Insufficient Verification of Data Authenticity.
Takeaway
Verify HMAC on every webhook. Raw body. Timing-safe compare. Reject 401 on mismatch. Mesrai catches the gap.