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-Key header and claim it on a unique column, or with SET 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 (app.use) and NestJS interceptors, global ones included, as the idempotency mechanism; Stripe, PayPal, Adyen, Braintree, Square, Mollie, Checkout.com, Razorpay

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 StripeClient), PayPal (REST SDK and Server SDK), Braintree, Adyen, Square, Mollie, Checkout.com, Razorpay, with the idempotency key each is given (idempotency_key=, options={"idempotency_key": …}, paypal_request_id)

Java

Spring MVC, JAX-RS and servlets (Bean Validation @Positive, @Min, @DecimalMin, @Range under @Valid or @Validated, constraints of the project, Assert.isTrue, Preconditions.checkArgument) and the repositories Spring Data REST exports (@RepositoryRestResource); Spring Data JPA, MyBatis and MyBatis-Plus, an EntityManager, JDBC and JdbcTemplate, Spring’s JdbcClient, jOOQ, QueryDSL, MongoDB and Redis counters, a stored procedure handed money, and a unique key declared only in the SQL schema; filters, interceptors and controller advice as the idempotency mechanism; Stripe, Braintree, PayPal, Adyen, Square, Mollie, Checkout.com, Razorpay

Kotlin

Spring MVC (@RequestParam, @PathVariable, @RequestBody, @RequestHeader, a simple parameter with no annotation, the servlet request; Bean Validation @Positive, @Min, @DecimalMin, also as @field:, and constraints of the project composed of them; require, check, Assert.isTrue, Preconditions.checkArgument, when entries) and Ktor (call.receive<T>(), receiveParameters(), call.parameters, getOrFail); Spring Data JPA repositories (save, saveAll, apply/also blocks, @Modifying @Query), JdbcTemplate, and Exposed (insert, update, it.update(column, …), new, uniqueIndex()), and a unique key declared only in the SQL schema or a duplicate key caught after the insert; filters, interceptors, controller advice and Ktor plugins as the idempotency mechanism; Stripe, Braintree, PayPal, Adyen, Square, Mollie, Checkout.com, Razorpay

Go

net/http, Gin, Echo, Fiber, with the request’s body decoded into a struct (ShouldBindJSON, Bind, json.NewDecoder(r.Body).Decode, BodyParser); GORM (Create, Save, Update, Updates, gorm.Expr), ent (SetX/AddX … Save) and database/sql, sqlx or pgx (Exec of an UPDATE … SET or an INSERT); binding and validate tags (gt, gte, min), ozzo-validation, unsigned types, math.Abs, max(0, x); gorm:"uniqueIndex", gorm:"unique", ent’s Unique(); route middleware as the idempotency mechanism; stripe-go, braintree-go, PayPal (plutov/paypal), Adyen, Square, Checkout.com, Razorpay

C#

ASP.NET Core controllers ([FromQuery], [FromRoute], [FromBody], [FromHeader], and the binding ASP.NET Core infers with none; [Range] under [ApiController] or a ModelState.IsValid check; FluentValidation’s RuleFor(…).GreaterThan(0), IValidatableObject, validation attributes of the project, Math.Clamp; Request.ReadFromJsonAsync<T>(), Request.Body, JsonDocument) and minimal APIs, registered as a method group or written in line; EF Core (Add/Update on a DbSet or the context, a change to a row a query loaded saved by SaveChanges, ExecuteUpdate with SetProperty, Entry(…).Property(…).CurrentValue, CurrentValues.SetValues, AutoMapper’s Map; [Index(…, IsUnique = true)], HasIndex(…).IsUnique()), linq2db and SQL written by hand (Dapper Execute, ExecuteSqlRaw, ExecuteSqlInterpolated, ADO.NET commands with SqlParameter); filters and middleware as the idempotency mechanism; Stripe.net, Braintree, PayPal, Adyen, Square, Mollie, Checkout.com, Razorpay, and HttpClient calls to Stripe’s and PayPal’s API

PHP

Laravel actions ($request→input(), $request→amount, $request→validated(), $request→validate([…​]), request(), route parameters, lists the body carries; validation rules min:, gt:, gte:, between: in the action or a Form Request, also for each element of a list (items.*.quantity); unique in a migration or the SQL schema) and Symfony actions (the request’s bags, payload and decoded body; [MapRequestPayload] with [Assert\Positive], GreaterThan, Range) on Eloquent (create, update, save, fill, a whole validated body, relations and the authenticated user, increment/decrement, firstOrCreate), the query builder (DB::table(), DB::raw(), SQL with its bindings) and Doctrine (flush() after a setter, persist(), DQL); route, group and controller middleware as the idempotency mechanism; Stripe (also Checkout), PayPal, Adyen, Braintree, Square, Mollie, Checkout.com, Razorpay

Ruby

Rails (params[:amount], params.fetch, strong parameters params.require(…).permit(…), also returned by a private method; request.headers) and Sinatra (params['amount'], request.env['HTTP_…']); ActiveRecord (create, update, save after assigning the row or assign_attributes, increment!/decrement!, update_column, find_or_create_by; validates … numericality: { greater_than: … }, validates … uniqueness: true, unique indexes in db/schema.rb or a migration, also over several columns, and a unique key declared only in the SQL schema (db/structure.sql)); associations (create!, build, <<), params.dig, permit!, hashes built by hand or merged, an update that sets the balance to the value the client sent, validations of a superclass or a concern, before_action/around_action filters (also from a concern) as the idempotency mechanism; Stripe, Braintree, PayPal, Adyen, Square, Mollie, Checkout.com, Razorpay, with the idempotency key each is given (idempotency_key:, Adyen’s Idempotency-Key header, Checkout.com’s key argument)

Swift

Vapor (req.content.decode(T.self), req.content.get(:at:), req.query.get(:at:), req.parameters.get(_:), req.headers.first(name:); Validatable with a lower end, .range(1…​), when the handler calls validate(content:); UInt; guard amount > 0, abs, max(0, …)); Fluent (save/create/update(on:) of a model read or built, query(on:).set(\.$field, to:).update(), .unique(on:) in a migration) and SQL given to SQLKit’s raw; StripeKit

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.

© 2026 Xygeni. All rights reserved