Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions __tests__/payments/razorpay-productionization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* #1377 — the two behavioural changes in the Razorpay productionization pass.
*
* 1. A RazorpayX payout that reaches the terminal `failed` state must map to
* FAILED. It used to fall through to the `default` arm and read as PENDING,
* which left the earnings BATCHED against a payout the bank had refused.
* 2. Rotating `RAZORPAY_WEBHOOK_SECRET` must not drop the deliveries signed
* with the old secret during the cutover, because Razorpay disables a
* webhook that fails for 24 hours and lost events cannot be replayed.
* 3. The `X-Payout-Idempotency` header must stay inside the length RazorpayX
* accepts, or the duplicate guard becomes a 400 on every live payout.
*/
import crypto from "node:crypto";

import {
isPayoutEventName,
matchRazorpayWebhookSecret,
resolveRazorpayPaymentSecrets,
verifyRazorpaySignature,
} from "@/app/api/webhooks/razorpay/signature";
import {
boundPayoutIdempotencyKey,
RazorpayPayoutsService,
} from "@/lib/payments/payouts/razorpay-payouts";

const RAW_BODY = JSON.stringify({
event: "payment.captured",
payload: { payment: { entity: { id: "pay_test" } } },
});

function sign(body: string, secret: string): string {
return crypto.createHmac("sha256", secret).update(body).digest("hex");
}

describe("RazorpayX payout status mapping", () => {
const service = new RazorpayPayoutsService({
keyId: "rzp_test_key",
keySecret: "secret",
accountNumber: "2323230000000000",
});

it("maps every terminal RazorpayX status to a terminal internal status", () => {
expect(service.mapPayoutStatus("failed")).toBe("FAILED");
expect(service.mapPayoutStatus("rejected")).toBe("FAILED");
expect(service.mapPayoutStatus("reversed")).toBe("FAILED");
expect(service.mapPayoutStatus("cancelled")).toBe("CANCELLED");
expect(service.mapPayoutStatus("processed")).toBe("COMPLETED");
});

it("keeps the intermediate statuses non-terminal so the reconciler keeps polling", () => {
expect(service.mapPayoutStatus("queued")).toBe("PENDING");
expect(service.mapPayoutStatus("pending")).toBe("PENDING");
expect(service.mapPayoutStatus("processing")).toBe("PROCESSING");
});
});

// The resolver takes the environment as an argument precisely so these cases
// need no process.env mutation and cannot leak into a sibling suite.
describe("Razorpay webhook secret rotation grace", () => {
it("offers only the current secret when no rotation is in flight", () => {
const secrets = resolveRazorpayPaymentSecrets({
RAZORPAY_WEBHOOK_SECRET: "current_secret",
});

expect(secrets).toEqual([{ role: "current", value: "current_secret" }]);
});

it("offers nothing at all when the current secret is missing", () => {
// The grace window is an aid to a rotation, never a standalone secret: a
// deployment that has lost the current value must fail loudly rather than
// keep accepting deliveries on the retired one.
expect(
resolveRazorpayPaymentSecrets({
RAZORPAY_WEBHOOK_SECRET_PREVIOUS: "old_secret",
}),
).toEqual([]);
expect(
resolveRazorpayPaymentSecrets({
RAZORPAY_WEBHOOK_SECRET: " ",
RAZORPAY_WEBHOOK_SECRET_PREVIOUS: "old_secret",
}),
).toEqual([]);
});

it("offers the previous secret second, and never duplicates the current one", () => {
expect(
resolveRazorpayPaymentSecrets({
RAZORPAY_WEBHOOK_SECRET: "new_secret",
RAZORPAY_WEBHOOK_SECRET_PREVIOUS: "old_secret",
}),
).toEqual([
{ role: "current", value: "new_secret" },
{ role: "previous", value: "old_secret" },
]);

expect(
resolveRazorpayPaymentSecrets({
RAZORPAY_WEBHOOK_SECRET: "same",
RAZORPAY_WEBHOOK_SECRET_PREVIOUS: "same",
}),
).toEqual([{ role: "current", value: "same" }]);
});

it("accepts a delivery signed with either secret and reports which one matched", () => {
const candidates = resolveRazorpayPaymentSecrets({
RAZORPAY_WEBHOOK_SECRET: "new_secret",
RAZORPAY_WEBHOOK_SECRET_PREVIOUS: "old_secret",
});

expect(
matchRazorpayWebhookSecret(
RAW_BODY,
sign(RAW_BODY, "new_secret"),
candidates,
),
).toBe("current");
expect(
matchRazorpayWebhookSecret(
RAW_BODY,
sign(RAW_BODY, "old_secret"),
candidates,
),
).toBe("previous");
expect(
matchRazorpayWebhookSecret(
RAW_BODY,
sign(RAW_BODY, "attacker_secret"),
candidates,
),
).toBeNull();
});
Comment thread
teetangh marked this conversation as resolved.

it("rejects a malformed signature instead of throwing out of timingSafeEqual", () => {
expect(() =>
verifyRazorpaySignature(RAW_BODY, "not-a-hex-digest", "current_secret"),
).not.toThrow();
expect(
verifyRazorpaySignature(RAW_BODY, "not-a-hex-digest", "current_secret"),
).toBe(false);
expect(
verifyRazorpaySignature(
RAW_BODY,
sign(RAW_BODY, "current_secret").slice(0, 63),
"current_secret",
),
).toBe(false);
});

it("only classifies payout.* bodies as eligible for the RazorpayX secret", () => {
expect(
isPayoutEventName(JSON.stringify({ event: "payout.processed" })),
).toBe(true);
expect(isPayoutEventName(RAW_BODY)).toBe(false);
expect(isPayoutEventName("{ not json")).toBe(false);
});
});

describe("RazorpayX payout idempotency header", () => {
// Both real key shapes overshoot the gateway's 36-character ceiling, so the
// bound is what stands between a deduplicated retry and a rejected payout.
const orgKey = "payout_11111111-2222-4333-8444-555555555555";
const consultantKey =
"payout_11111111-2222-4333-8444-555555555555_batch_1756900000000_abcdef12";

it("keeps a key the gateway already accepts", () => {
expect(boundPayoutIdempotencyKey("payout_ckv1v0h8n0000abcdefghijkl")).toBe(
"payout_ckv1v0h8n0000abcdefghijkl",
);
});

it("folds an over-long key into the accepted length, deterministically", () => {
for (const key of [orgKey, consultantKey]) {
const bounded = boundPayoutIdempotencyKey(key);
expect(bounded.length).toBeLessThanOrEqual(36);
expect(bounded).toMatch(/^[A-Za-z0-9 _-]+$/);
expect(boundPayoutIdempotencyKey(key)).toBe(bounded);
}
expect(boundPayoutIdempotencyKey(orgKey)).not.toBe(
boundPayoutIdempotencyKey(consultantKey),
);
});
});
125 changes: 64 additions & 61 deletions app/api/webhooks/razorpay/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as Sentry from "@sentry/nextjs";
import { NextRequest, NextResponse } from "next/server";
import { after } from "next/server";
import crypto from "node:crypto";
import { verifyWebhookSignature, logWebhookEvent, isDbHealthy } from "../utils";
import { logWebhookEvent, isDbHealthy } from "../utils";
import { recordSystemEvent } from "@/lib/enterprise/system-events";
import {
razorpayWebhookEnvelopeSchema,
Expand All @@ -12,11 +12,27 @@ import {
// stuck-webhook sweeper (jobs/cleanup/sweep-stuck-webhook-events) can replay
// crashed events through the exact same handler routing.
import { processRazorpayWebhookEvent } from "../razorpay-dispatch";
import {
isPayoutEventName,
matchRazorpayWebhookSecret,
resolveRazorpayPaymentSecrets,
verifyRazorpaySignature,
} from "./signature";

// #1377 — signature verification needs `node:crypto`, which the edge runtime
// does not provide. Node is already the App Router default for route handlers;
// pinning it here means a future project-wide default flip cannot silently
// break every inbound payment confirmation.
export const runtime = "nodejs";

export async function POST(req: NextRequest) {
const secret = process.env.RAZORPAY_WEBHOOK_SECRET;
Sentry.setTag("subsystem", "payments");
if (!secret) {

// #1377 — the payment-side secrets, current first and (only during a
// rotation) the previous one. See resolveRazorpayPaymentSecrets for why the
// grace window exists: a hard cutover loses events permanently.
const paymentSecrets = resolveRazorpayPaymentSecrets();
if (paymentSecrets.length === 0) {
console.error("RAZORPAY_WEBHOOK_SECRET not configured");
return NextResponse.json(
{ error: "Webhook secret not configured" },
Expand All @@ -29,74 +45,61 @@ export async function POST(req: NextRequest) {
// is configured, re-verify with it (for payout.* events).
const razorpayXSecret = process.env.RAZORPAYX_WEBHOOK_SECRET;

const { isValid, body } = await verifyWebhookSignature(
req,
secret,
"razorpay",
);
const signature = req.headers.get("x-razorpay-signature");
// The HMAC covers the RAW bytes. Read them once here and hand the same
// string to every verification attempt — parsing and re-serialising would
// reorder keys and break the digest.
const body = signature ? await req.text() : "";

const matchedRole = signature
? matchRazorpayWebhookSecret(body, signature, paymentSecrets)
: null;

if (matchedRole === "previous") {
// The rotation grace is meant to be short. Every delivery that only the
// OLD secret can verify is reported so a variable left behind after the
// cutover shows up in the operations timeline instead of quietly
// extending the window forever.
await recordSystemEvent({
category: "WEBHOOK",
severity: "WARN",
message:
"Razorpay webhook verified with RAZORPAY_WEBHOOK_SECRET_PREVIOUS — rotation grace still in use",
context: { provider: "razorpay" },
});
}

if (!isValid) {
if (!matchedRole) {
// M2 FIX: Only allow RazorpayX secret fallback for payout.* events.
// Parse the body to check event type before re-verifying — this prevents
// non-payout events from being accepted with the RazorpayX secret.
let isPossiblyPayoutEvent = false;
try {
const parsed = JSON.parse(body);
isPossiblyPayoutEvent =
typeof parsed.event === "string" && parsed.event.startsWith("payout.");
} catch {
// Can't parse — not a valid webhook, reject
}
// Read the event name from the (still unverified) body first — this
// prevents non-payout events from being accepted with the RazorpayX
// secret, and can only ever narrow what we accept.
const isPossiblyPayoutEvent = signature ? isPayoutEventName(body) : false;

if (
const razorpayXAccepted =
isPossiblyPayoutEvent &&
razorpayXSecret &&
razorpayXSecret !== secret
) {
const signature = req.headers.get("x-razorpay-signature");
if (signature) {
const crypto = await import("crypto");
const expectedSig = crypto
.createHmac("sha256", razorpayXSecret)
.update(body)
.digest("hex");
const sigBuf = Buffer.from(signature, "hex");
const expectedBuf = Buffer.from(expectedSig, "hex");
const isRazorpayXValid =
sigBuf.length === expectedBuf.length &&
crypto.timingSafeEqual(sigBuf, expectedBuf);

if (!isRazorpayXValid) {
// #776 §K — repeated HMAC failures are a tamper/misconfig signal.
await recordSystemEvent({
category: "WEBHOOK",
severity: "WARN",
message:
"Razorpay webhook HMAC verification failed (RazorpayX secret)",
context: { provider: "razorpayx", event: "payout.*" },
});
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 400 },
);
}
// RazorpayX signature valid for payout event — continue processing
} else {
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 400 },
);
}
} else {
!!signature &&
!!razorpayXSecret &&
!paymentSecrets.some(
(candidate) => candidate.value === razorpayXSecret,
) &&
verifyRazorpaySignature(body, signature, razorpayXSecret);

if (!razorpayXAccepted) {
// #776 §K — repeated HMAC failures are a tamper/misconfig signal.
await recordSystemEvent({
category: "WEBHOOK",
severity: "WARN",
message: "Razorpay webhook HMAC verification failed",
context: { provider: "razorpay" },
message: isPossiblyPayoutEvent
? "Razorpay webhook HMAC verification failed (RazorpayX secret)"
: "Razorpay webhook HMAC verification failed",
context: isPossiblyPayoutEvent
? { provider: "razorpayx", event: "payout.*" }
: { provider: "razorpay" },
});
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// RazorpayX signature valid for payout event — continue processing
}

// DB health check — return 503 if DB is unreachable so Razorpay retries
Expand Down Expand Up @@ -171,7 +174,7 @@ export async function POST(req: NextRequest) {
eventId,
eventType,
event.payload,
req.headers.get("x-razorpay-signature") || undefined,
signature || undefined,
);

if (!isNew) {
Expand Down
Loading
Loading