Unbounded Amount

ID

unbounded_amount

Severity

high

Remediation Complexity

trivial

Remediation Risk

low

Remediation Effort

low

Family

API6:2023 - Unrestricted Access to Sensitive Business Flows

CWE

CWE-839, CWE-1284

Resource

source

Language

JavaScript/TypeScript, Python, Java, Kotlin, Go, C#, PHP, Ruby, Swift

Description

An amount the client sends reaches a write of money with nothing keeping it at or above zero. A negative top-up debits the wallet it was meant to credit; a negative quantity, multiplied by the price at checkout, turns the charge into a credit.

The detector follows the endpoint’s handler into the services and helpers it calls, and follows each value of the request — req.body, req.query, req.params, the raw body, a NestJS @Body() DTO — through locals, destructuring, arithmetic and the calls it is passed to. It reports the value when it reaches, with no lower bound:

  • a field that holds money: the last word of its name is on the moneyFieldNames list of the detector’s configuration (balance, amount, price, total…), or the convention profile names it in moneyFields;

  • a call that moves money: a payment SDK’s (Stripe, PayPal, Adyen, Braintree), or one the profile names in moneySinks;

  • a field the application multiplies by money in another handler: a basket item’s quantity, saved here and multiplied by the product’s price at checkout.

The generic CRUD of finale-rest saves a model’s fields as the client sends them with no handler of the application’s own; a model exposed that way whose field is multiplied by money is reported at the finale.resource(…) call.

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

The sign of the amount decides which way the money moves. Sending -500 to a top-up endpoint withdraws instead of depositing; a basket with a quantity of -3 pays the customer at checkout.

Example

router.put('/wallet/balance', async (req, res) => {
  const { amount } = req.body;
  await Account.increment({ balance: amount }, { where: { userId: req.user.id } });  // a negative top-up debits
  res.sendStatus(200);
});

Fixed:

router.put('/wallet/balance', async (req, res) => {
  const { amount } = req.body;
  if (!(amount > 0)) {
    return res.sendStatus(400);
  }
  await Account.increment({ balance: amount }, { where: { userId: req.user.id } });
  res.sendStatus(200);
});

What counts as a lower bound

  • A check that turns a small value away before the write: if (amount ⇐ 0) return …, if (quantity < 1) throw ….

  • A check the write sits behind: if (amount > 0) { … }.

  • A class-validator decorator on the DTO field: @IsPositive(), @Min(n) with n not negative.

  • A Joi schema the value is validated against, with .positive(), or .min(n) or .greater(n) for an n not negative: schema.validate(req.body), Joi.attempt(body, schema), or celebrate on the route.

  • Math.abs(amount), Math.max(0, amount); in Python, abs(amount) and max(0, amount).

  • For finale-rest, a write hook that refuses the value: resource.update.write.before(…).

An upper cap (if (amount > LIMIT) return …) is not a lower bound: it lets a negative amount through.

A value handed, before the write, to a call that verifies a payment provider’s signature over it ($this→payU→verifyHash($request→all()), a name on signatureVerifications) is not reported: the provider signed the figure, and the client cannot change it.

Remediation

  • Reject an amount below the smallest one the operation allows before it reaches the write; !(amount > 0) also turns a missing or non-numeric amount away.

  • Or declare it on the body with class-validator (@IsPositive(), @Min(1)) and validate it with a ValidationPipe.

  • For a generic CRUD, add a write hook that refuses it.

Not Reported

  • An amount computed on the server from what the database holds.

  • A catalogue’s own price set by the client (PUT /products/:id): it is not an amount moved. client_supplied_price reports a price that reaches an order.

  • A field that holds no money and that the application never multiplies by money.

Coverage

Language Covered

JavaScript and TypeScript

Express, NestJS (@Body(), @Query(), @Param() and class-validator), Joi and celebrate, finale-rest; 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 and forms: min_value, MinValueValidator, extra_kwargs, validate_<field> and clean_<field>; generic views and ViewSets), Flask (Flask-WTF NumberRange, marshmallow Range), FastAPI and Pydantic (Field(gt=…), conint, PositiveInt, Annotated[…, Gt(0)], field validators); the Django ORM, SQLAlchemy, Peewee, Tortoise and SQL run on a cursor; Stripe (also a StripeClient), PayPal (REST SDK and Server SDK), Braintree, Adyen, Square, Mollie, Checkout.com, Razorpay

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, validations of a superclass or a concern, before_action/around_action filters (also from a concern) as the idempotency mechanism; Stripe (also Checkout), Braintree, PayPal, Adyen, Square, Mollie, Checkout.com, Razorpay

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

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. The convention profile adds moneyFields and moneySinks.

© 2026 Xygeni. All rights reserved