Skip to content

Comments

fix: prevent payment race condition with atomic balance check#1424

Open
theeggorchicken wants to merge 1 commit intoidurar:devfrom
theeggorchicken:fix/payment-race-condition
Open

fix: prevent payment race condition with atomic balance check#1424
theeggorchicken wants to merge 1 commit intoidurar:devfrom
theeggorchicken:fix/payment-race-condition

Conversation

@theeggorchicken
Copy link

Description

The payment controller in create.js reads the invoice, checks remaining balance, then creates the payment and updates the invoice in separate operations. There's no locking between the read and the write. If you fire multiple payment requests at the same time, they all read the same balance, all pass the check, and all go through.

I tested this with 10 concurrent $500 payment requests against a $500 invoice. All 10 succeeded, leaving the invoice with $5,000 in credit on a $500 total.

Vulnerable Lines

File: backend/src/controllers/appControllers/paymentController/create.js#L19-L41

const currentInvoice = await Invoice.findOne({    // READ (line 19)
  _id: req.body.invoice, removed: false,
});
const maxAmount = calculate.sub(                   // CHECK (line 30)
  calculate.sub(previousTotal, previousDiscount),
  previousCredit
);
if (req.body.amount > maxAmount) { return ... }    // passes for all concurrent requests
const result = await Model.create(req.body);       // WRITE (line 41) -- no lock

The window between findOne and create is wide enough that even a single-process Node.js server allows the race. No special multi-worker configuration needed.

How to verify

import asyncio, aiohttp

async def race():
    async with aiohttp.ClientSession() as s:
        tasks = [s.post('http://localhost:8888/api/payment/create',
                        json={'amount': 500, 'invoice': '<invoice_id>',
                              'date': '2026-02-20', 'number': i+1, 'year': 2026,
                              'paymentMode': 'cash', 'type': 'payment'},
                        headers={'Authorization': 'Bearer <token>'})
                 for i in range(10)]
        results = await asyncio.gather(*tasks)
        successes = sum(1 for r in results if r.status == 200)
        print(f'{successes}/10 payments accepted')

asyncio.run(race())
# Before fix: 10/10. After fix: 1/10.

Then check the invoice:

curl http://localhost:8888/api/invoice/read/<invoice_id>
# credit: 5000 on a total: 500 invoice

What this PR does

Replaces the read-then-check-then-write pattern with a single findOneAndUpdate that uses $expr to atomically verify the remaining balance and reserve the credit in one operation. MongoDB guarantees findOneAndUpdate is atomic at the document level, so only one concurrent request can claim the remaining balance.

const invoiceUpdate = await Invoice.findOneAndUpdate(
  {
    _id: req.body.invoice,
    removed: false,
    $expr: {
      $gte: [
        { $subtract: [{ $subtract: ['$total', '$discount'] }, '$credit'] },
        req.body.amount,
      ],
    },
  },
  { $inc: { credit: req.body.amount } },
  { new: true }
);

If invoiceUpdate is null, the balance was insufficient (another request got there first), and we return the same "max amount" error as before.

Evidence

Full curl --trace-ascii captures and parallel race results: evidence gist

Related Issue

Security fix -- TOCTOU race condition in payment processing.

Steps to Test

  1. Start the backend and create an invoice with a known total
  2. Fire 10+ concurrent payment requests for the full invoice amount
  3. Before fix: all succeed, credit exceeds total. After fix: only one succeeds.

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.

The payment controller reads the invoice balance, checks if the
requested amount fits, then creates the payment and increments credit
in separate operations. Concurrent requests all read the same balance,
all pass the check, and all create payments - resulting in credit
exceeding the invoice total.

Tested: 10 concurrent $500 payments against a $500 invoice all
succeeded, producing $5,000 in credit on a $500 invoice.

Replaces the read-then-check-then-write pattern with a single
findOneAndUpdate using $expr to atomically verify remaining balance
and reserve credit. Only one concurrent request can succeed when
the remaining balance is insufficient for multiple payments.
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.

1 participant