Financial Write Without Idempotency
ID |
financial_write_without_idempotency |
Severity |
high |
Remediation Complexity |
medium |
Remediation Risk |
medium |
Remediation Effort |
medium |
Family |
API6:2023 - Unrestricted Access to Sensitive Business Flows |
CWE |
CWE-837 |
Resource |
source |
Language |
JavaScript/TypeScript, Python, Java, Kotlin, Go, C#, PHP, Ruby, Swift |
Description
A handler moves money and nothing makes a repeated request a no-op. A client that retries after a timeout, a double click, or a replayed request charges, refunds or credits a second time.
The detector reads only the handlers that move money: they write a field that holds money (a name on
moneyFieldNames, or a moneyFields fact of the convention profile), or call a payment
SDK or a moneySinks call of the profile, in the handler or in what it calls. It reports one when,
up to its first money effect, it finds none of:
-
an idempotency key read from the request: a header or a field on
idempotencyKeyNames(Idempotency-Key); -
an insert of a row whose unique key the client’s reference sets (
Payment.reference), even when it is the first money effect: a replay fails on the key; -
a read that turns a repeated request away: a row required absent and then created (
if (applied) return; AppliedCoupon.create(…)), or a state of the operation checked and then changed (if (invoice.status === 'PAID') return; … invoice.update({ status: 'PAID' })).
A balance checked before it is debited is not a deduplication: a retry passes the same check.
A write of a pricing rule, of a cart, or of a line of a cart, an order or a document, moves no money
whatever money field it sets (a discount rule’s discount_amount, a cart’s grand_total, a cart item’s
price), and is not reported:
the entities are named, by the last word of their name, on entitiesMovingNoMoney.
Convention profile
When the profile names the application’s own idempotency mechanism in idempotencyMechanisms — a
middleware on the route, a decorator, a call on the handler’s path — and some money handler goes
through it, a handler that goes through it is not reported, and a suppression record at info names
the fact. A money handler that does not go through it is reported as the one that skips the
convention the others follow.
When the analysis of your application names the field or the call as money, the finding is reviewed again, and it can end up as info if that review concludes that it does not move money.
Security Impact
Money moves once per request rather than once per operation: a customer pays twice for one order, a refund is issued twice, a reward is credited as often as the request is sent.
Example
router.post('/invoices/pay', async (req, res) => {
const invoice = await Invoice.findOne({ where: { userId: req.user.id, number: req.body.number } });
await Account.decrement({ balance: invoice.amount }, { where: { userId: req.user.id } }); // twice on a retry
await invoice.update({ status: 'PAID' });
res.sendStatus(200);
});
Fixed:
router.post('/invoices/pay', async (req, res) => {
const invoice = await Invoice.findOne({ where: { userId: req.user.id, number: req.body.number } });
if (invoice.status === 'PAID') {
return res.sendStatus(409);
}
await Account.decrement({ balance: invoice.amount }, { where: { userId: req.user.id } });
await invoice.update({ status: 'PAID' });
res.sendStatus(200);
});
Two concurrent requests can still both find the invoice unpaid: check_then_act_without_guard
reports that race.
Remediation
-
Require an
Idempotency-Keyheader and claim it on a unique column, or withSET NX, before moving the money; answer a repeated key with the first response. -
Or write the operation with a unique reference the client sends, so a replay fails on the key.
-
Or record the operation’s state, and return when it is already done.
Coverage
| Language | Covered |
|---|---|
JavaScript and TypeScript |
Express, NestJS; Sequelize, TypeORM, Prisma, Mongoose, knex; a unique key declared only in the SQL schema; route, router and application middleware ( |
Python |
Django and DRF (serializers, forms, generic views and ViewSets), Flask (Flask-WTF, marshmallow), FastAPI and Pydantic; the Django ORM, SQLAlchemy, Peewee, Tortoise and SQL run on a cursor; a unique key declared only in the SQL schema; an update that sets the balance to the value the client sent; decorators of the project that read the key as the idempotency mechanism; Stripe (also a |
Java |
Spring MVC, JAX-RS and servlets (Bean Validation |
Kotlin |
Spring MVC ( |
Go |
net/http, Gin, Echo, Fiber, with the request’s body decoded into a struct ( |
C# |
ASP.NET Core controllers ( |
PHP |
Laravel actions ( |
Ruby |
Rails ( |
Swift |
Vapor ( |
Configuration
idempotencyKeyNames lists the headers and fields that carry an idempotency key, case, - and _
aside. The deduplication is recognised with the checkThenAct vocabulary of
check_then_act_without_guard; customise it there, and it applies to both rules.
The detector’s properties list, in its own configuration, the money field names (moneyFieldNames), the
payment SDKs' calls (paymentSdks) and, per language, how a value of the request is followed to a write
(flow: the request members, the ORM calls, the validators, Joi and finale-rest). A key overridden in
the detector’s custom configuration changes it for this detector alone.