Skip to content

fix(api): prevent payment amount spoofing in createOrder and add replay protection - #274

Merged
hrx01-dev merged 1 commit into
hrx01-dev:mainfrom
Harsh-goswami-103:fix/252-payment-amount-spoofing
Jul 9, 2026
Merged

fix(api): prevent payment amount spoofing in createOrder and add replay protection#274
hrx01-dev merged 1 commit into
hrx01-dev:mainfrom
Harsh-goswami-103:fix/252-payment-amount-spoofing

Conversation

@Harsh-goswami-103

@Harsh-goswami-103 Harsh-goswami-103 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Problem

Fixes #252

api/razorpay.tsverifyPayment already validates the payment amount against Razorpay's server-side records (via payments.fetch), which prevents the specific exploit of sending a spoofed large amount in the request body.

However, two related vulnerabilities remained:

1. createOrder accepts arbitrary amounts

A malicious client could call createOrder with a tiny amount (e.g. ₹1), pay it legitimately, and then verifyPayment would record that tiny amount — allowing underpayment to go undetected.

2. No replay protection in verifyPayment

A captured payment could be submitted multiple times, each time appending a new payment record and inflating the paid balance.

Fix

createOrder — Server-side amount validation

  • Initialize Firebase Admin in the createOrder path
  • Fetch the client's billing record from Firestore
  • Calculate outstanding balance: totalCost - sum(completed payments)
  • Reject if the requested amount exceeds the outstanding balance (with 1 paisa tolerance for floating-point rounding)

verifyPayment — Replay protection

  • Before recording a payment, check if the razorpay_payment_id already exists in the payments array (by id or reference field)
  • Return HTTP 409 if duplicate detected

Tests Added

  • createOrder: rejects when billing record is missing
  • createOrder: rejects when amount exceeds outstanding balance
  • createOrder: allows amount within outstanding balance
  • verifyPayment: rejects duplicate payment ID (replay protection)

All 16 tests pass ✅

Files Changed

  • api/razorpay.ts — core fix
  • src/test/razorpay-endpoint.test.ts — new test coverage

Summary by CodeRabbit

  • Bug Fixes

    • Added balance checks before creating payment orders, preventing requests that exceed the remaining amount due.
    • Improved payment verification to reject duplicate payment records and avoid double-processing.
    • Added clearer error responses when billing records are missing or payment setup fails.
  • Tests

    • Expanded coverage for order creation, missing billing records, balance validation, and duplicate payment protection.

…replay protection

- createOrder now checks the requested amount against the client's
  outstanding balance in Firestore (totalCost minus completed payments)
  so a malicious client cannot create orders for arbitrary amounts.
- verifyPayment now rejects duplicate razorpay_payment_id values to
  prevent replay attacks that could inflate the paid balance.
- Added test coverage for both new validations.

Closes hrx01-dev#252
@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

@Harsh-goswami-103 is attempting to deploy a commit to the hrx01-dev's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The createOrder endpoint in api/razorpay.ts now validates requested amounts against a Firestore-backed outstanding balance, initializing Firebase Admin and reading the client's projectBilling record, returning 404/500/400 for missing billing, init failure, or excess amount. verifyPayment adds replay protection, returning 409 if the payment ID/reference was already recorded. Tests are extended to cover these cases.

Changes

Razorpay billing validation and replay protection

Layer / File(s) Summary
Outstanding balance validation in createOrder
api/razorpay.ts, src/test/razorpay-endpoint.test.ts
createOrder initializes Firebase Admin, reads the client's projectBilling Firestore record, computes remaining balance from totalCost minus completed payments, and rejects requests exceeding it (404 missing billing, 500 init failure, 400 excess amount); tests cover missing billing, exceeding balance, and within-balance cases.
Replay protection in verifyPayment
api/razorpay.ts, src/test/razorpay-endpoint.test.ts
verifyPayment checks existing payments for a matching razorpay_payment_id/reference and returns 409 if already recorded, with a test validating duplicate detection.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant createOrder
  participant FirebaseAdmin
  participant Firestore

  Client->>createOrder: request order with amount
  createOrder->>FirebaseAdmin: initAdmin()
  FirebaseAdmin-->>createOrder: initialized (or fails -> 500)
  createOrder->>Firestore: read projectBilling/<email>
  Firestore-->>createOrder: billing doc (or missing -> 404)
  createOrder->>createOrder: compute remaining = totalCost - paidSoFar
  alt amount exceeds remaining
    createOrder-->>Client: 400 amount exceeds balance
  else amount within remaining
    createOrder-->>Client: 200 order created
  end
Loading
sequenceDiagram
  participant Client
  participant verifyPayment
  participant Firestore

  Client->>verifyPayment: submit razorpay_payment_id
  verifyPayment->>Firestore: read existing payments
  Firestore-->>verifyPayment: payments list
  alt payment_id already recorded
    verifyPayment-->>Client: 409 already recorded
  else new payment
    verifyPayment->>Firestore: update billing record
    verifyPayment-->>Client: success response
  end
Loading

Possibly related issues

Possibly related PRs

  • hrx01-dev/Servio#245: Introduced the original createOrder/verifyPayment endpoint that this PR extends with balance checks and replay protection.
  • hrx01-dev/Servio#262: Changed Firebase/Firestore initialization timing in the same api/razorpay.ts flows now relied on by the new billing check and replay protection.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR does not implement the linked fix: verifyPayment still lacks server-side Razorpay amount verification. Fetch the Razorpay order/payment in verifyPayment and persist the verified amount instead of any client-supplied amount.
Out of Scope Changes check ⚠️ Warning The createOrder balance enforcement and replay-protection changes go beyond linked issue #252's verifyPayment spoofing fix. Split the unrelated createOrder and replay-protection changes into separate PRs or add linked issues that explicitly require them.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main themes of the PR: createOrder balance checks and replay protection.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
api/razorpay.ts (1)

122-125: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use the shared collection constant here

"projectBilling" is defined in src/admin/lib/collections.ts as the single source of truth, and api/ already imports from src/ in this repo. Reuse COLLECTIONS.projectBilling here (and at the other call site) to avoid silent drift if the collection name changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/razorpay.ts` around lines 122 - 125, The billing lookup in the Razorpay
flow is hardcoding the projectBilling collection name instead of using the
shared source of truth. Update the db.collection call in this path, using the
existing COLLECTIONS.projectBilling constant from src/admin/lib/collections.ts,
and make the same replacement at the other call site so both references stay
aligned if the collection name changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@api/razorpay.ts`:
- Around line 264-274: The replay protection in verifyPayment is not atomic
because the payments read and the billing update happen separately, so
concurrent requests can both pass the alreadyRecorded check and duplicate the
payment. Move the check and write into a Firestore transaction around
billingRef, or switch to a payment_id-keyed document and use create() so
uniqueness is enforced by the database. Keep the existing duplicate detection
logic centered on razorpay_payment_id and the payments collection update path.

---

Nitpick comments:
In `@api/razorpay.ts`:
- Around line 122-125: The billing lookup in the Razorpay flow is hardcoding the
projectBilling collection name instead of using the shared source of truth.
Update the db.collection call in this path, using the existing
COLLECTIONS.projectBilling constant from src/admin/lib/collections.ts, and make
the same replacement at the other call site so both references stay aligned if
the collection name changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5aa2beb2-bf00-4a31-9228-cf48237816b2

📥 Commits

Reviewing files that changed from the base of the PR and between 34bc799 and 7ad1f86.

📒 Files selected for processing (2)
  • api/razorpay.ts
  • src/test/razorpay-endpoint.test.ts

Comment thread api/razorpay.ts
Comment on lines +264 to +274
// Replay protection: reject if this payment ID was already recorded so a
// captured payment can't be submitted twice to inflate the paid balance.
const alreadyRecorded = payments.some(
(p) => p.reference === razorpay_payment_id || p.id === razorpay_payment_id
);
if (alreadyRecorded) {
return res
.status(409)
.json({ error: "This payment has already been recorded" });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm no transaction/runTransaction wraps the verifyPayment read-check-update,
# and that billingRef.get()/update() are the only guards.
ast-grep outline api/razorpay.ts --items all
rg -nP 'runTransaction|\.get\(\)|\.update\(|payments\.some' api/razorpay.ts

Repository: hrx01-dev/Servio

Length of output: 814


Make the replay check atomic with the write

billingRef.get() and billingRef.update() are separate operations here, so two concurrent verifyPayment requests with the same razorpay_payment_id can both pass alreadyRecorded and append the payment. Move the read/check/update into a Firestore transaction, or use a payment_id-keyed doc with create() to enforce uniqueness.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/razorpay.ts` around lines 264 - 274, The replay protection in
verifyPayment is not atomic because the payments read and the billing update
happen separately, so concurrent requests can both pass the alreadyRecorded
check and duplicate the payment. Move the check and write into a Firestore
transaction around billingRef, or switch to a payment_id-keyed document and use
create() so uniqueness is enforced by the database. Keep the existing duplicate
detection logic centered on razorpay_payment_id and the payments collection
update path.

@hrx01-dev
hrx01-dev self-requested a review July 9, 2026 20:26

@hrx01-dev hrx01-dev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving this Ci green

@hrx01-dev
hrx01-dev merged commit ee2833e into hrx01-dev:main Jul 9, 2026
6 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Payment amount can be spoofed in verifyPayment (billing records don't match actual Razorpay charge

2 participants