SecLab

Business logic vulnerabilities

A06API6
01

What it is

A business logic flaw is when the attacker uses a feature exactly as written, in a way the designer never planned for. There is no payload, no special character, nothing to escape — just a sequence of legitimate operations reaching a wrong outcome.

02

Why you should care

Relevance: CoreExpected: L2

This is the family no tool can find, and the reason is fundamental rather than technical: for a scanner to find it, the scanner would have to know which outcome is correct, and that lives in the product owner's head, not in the code.

Three properties that set it apart from every other topic:

  • There is no general fix. SQL injection has one answer (parameterise). Logic flaws have as many fixes as they have variants, and each fix is a business decision.
  • It is not a bug in the code. The code does exactly what it was written to do. What is wrong is an assumption nobody wrote down: "quantities are always positive", "nobody changes the price after adding to cart", "step 3 is only reachable from step 2".
  • It is the most expensive family in money terms. It lives precisely where transactions are: prices, discounts, refunds, limits, approval workflows.

Which means defence here is not a programming technique but two practices: turning invariants into executable code, and testing by deliberately taking the wrong path. It is also the only topic where core applies to PM and CTO as much as to Dev — because most wrong assumptions are created while writing requirements, not while writing code.

03

How the attack works

There is no single mechanism. But almost every real logic flaw falls into one of five patterns, and knowing the five is the only way to hunt them systematically.

Diagram source
flowchart TD    A["Designed flow:<br/>cart → checkout → pay → ship"] --> P1    P1["① Skip a step<br/>call /ship directly"] --> X["Wrong outcome"]    P2["② Trust client data<br/>price=1 in the body"] --> X    P3["③ Out-of-domain value<br/>quantity=-5 → refund"] --> X    P4["④ Repeat past the limit<br/>apply the discount 10×"] --> X    P5["⑤ Unstated assumption<br/>change price AFTER the check"] --> X
PatternThe question that finds itConcrete example
① Skip a stepDoes step N verify step N−1 completed?POST /orders/7/ship without paying
② Trust client dataCould the server compute this value itself?{"price": 1} in the cart body
③ Out-of-domain valueWhat about negative, zero, enormous?quantity: -5 → negative total → refund
④ Repeat past the limitIs anybody counting?Applying the same discount 10× to one order
⑤ Unstated assumptionWhat is being taken as obvious?Changing the product price after the check ran

Pattern ② deserves its own note because it is both the most common and the easiest to spot: any value the server could compute but instead accepts from the client is a waiting hole. Price, tax, shipping, discount amount, userId, role, isAdmin, total. The one-line test question: "if the client sends a different value, would the server notice?"

Pattern ③ has a subtle variant: integer overflow. quantity: 2147483647 plus one wraps negative in an int, and price × quantity overflows into a small total. This is why value domains need an upper bound too, not just a floor.

Diagram description: The diagram shows the designed flow of cart then checkout then pay then ship, and five attack patterns all reaching the same wrong outcome: skipping a step by calling the ship endpoint directly; trusting client data by sending price=1 in the body; sending an out-of-domain value such as quantity=-5 so the total goes negative and becomes a refund; repeating past the limit by applying a discount ten times; and exploiting an unstated assumption by changing the price after the validation step already ran.

04

Concrete example

One checkout flow, four wrong paths — and every request is a legitimate request.

HTTP
# ② Trusting client data. The price is in the body, and the server uses it.POST /api/cart/items HTTP/1.1{"productId":"iphone-16-pro","quantity":1,"unitPrice":1} HTTP/1.1 200 OK{"cartTotal":"1","currency":"VND"}
HTTP
# ③ Out-of-domain value. Nobody wrote "quantity must be > 0" as code.POST /api/cart/items HTTP/1.1{"productId":"airpods","quantity":-3} HTTP/1.1 200 OK{"cartTotal":"-8970000"}      ← paying a negative total is a refund
HTTP
# ① Skipping a step. This endpoint has [Authorize], and that is all it checks.POST /api/orders/8821/ship HTTP/1.1 HTTP/1.1 200 OK{"status":"Shipped","trackingCode":"VN881…"}    ← nobody paid
HTTP
# ⑤ Unstated assumption: the price is validated at checkout, then re-read at capture.POST /api/checkout/8821          → 200, total = 32,000,000PATCH /api/cart/items/1          → {"quantity": 1}   (down from 10)POST /api/checkout/8821/capture  → 200, charged 3,200,000, shipped 10 units

Not one of those requests contains anything a WAF could see.

C#Three patterns in one handler: trusting the client price, no domain, no state check.
// ❌ Mẫu ② — DTO đầu vào chứa UnitPrice. Server BIẾT giá; nó nằm trong bảng product.//    Nhận nó từ client nghĩa là client quyết định giá, và không có phép kiểm nào//    ở dưới sửa được điều đó — vì không có gì để so sánh với.public record AddToCartRequest(string ProductId, int Quantity, decimal UnitPrice); [HttpPost("/api/cart/items")]public async Task<IActionResult> AddItem([FromBody] AddToCartRequest req, CancellationToken ct){    var cart = await _carts.GetForUserAsync(_currentUser.UserId, ct);     // ❌ Mẫu ③ — không có sàn và không có trần. Quantity = -3 cho tổng âm;    //    Quantity = 2147483647 tràn int khi nhân và cũng cho tổng âm.    //    Không ai viết "quantity > 0" ra thành code, vì nó "hiển nhiên".    cart.Items.Add(new CartItem(req.ProductId, req.Quantity, req.UnitPrice));     cart.Total = cart.Items.Sum(i => i.Quantity * i.UnitPrice);    await _carts.SaveAsync(cart, ct);     return Ok(new { cartTotal = cart.Total });} [HttpPost("/api/orders/{id:long}/ship")]public async Task<IActionResult> Ship(long id, CancellationToken ct){    var order = await _orders.GetByIdAsync(id, _currentUser.UserId, ct);    if (order is null) return NotFound();     // ❌ Mẫu ① — [Authorize] đã chạy và ownership đã kiểm, nên đoạn này TRÔNG như    //    đã đủ. Nhưng không có gì kiểm đơn đã được TRẢ TIỀN: thứ tự các bước chỉ    //    tồn tại trong thứ tự mà UI gọi endpoint.    order.Status = OrderStatus.Shipped;    order.TrackingCode = await _shipping.CreateLabelAsync(order, ct);    await _orders.SaveAsync(order, ct);     return Ok(new { status = order.Status, trackingCode = order.TrackingCode });}
TypeScriptThe same two patterns in Node: price from the body, and no order state check.
app.post("/api/checkout", async (req, res) => {  const { items, total } = req.body;   // ❌ Mẫu ② — total tới từ client. Server tính được nó, nên nhận nó nghĩa là  //    client quyết định số tiền bị trừ.  const charge = await stripe.charges.create({ amount: total, currency: "vnd" });   const order = await db.orders.create({ userId: req.user.id, items, total });   // ❌ Mẫu ① — không có gì kiểm charge đã thành công. charge.status có thể là  //    "pending" hoặc "failed", và đơn vẫn sang Paid.  await db.orders.update(order.id, { status: "paid" });   return res.json({ orderId: order.id });});
05

What happened in the wild

Starbucks, 2015 (Egor Homakov) — transferring a balance between two gift cards concurrently created money from nothing. Pattern ⑤ plus a race condition: the assumption "one transfer debits once" was never written as a constraint.

Negative-price bugs in e-commerce, a recurring pattern. Many public bug bounty reports (HackerOne) describe the same thing: a negative quantity makes the total negative, and the payment gateway treats a negative total as a refund. Memorable because it is pattern ③ at its simplest, and it still appears every year — not because it is hard to fix, but because nobody wrote "quantity > 0" as an invariant.

Uber, 2017 — free rides via a payment account that did not exist. The flow let you pick a payment method then confirm, and a paymentProfileUUID not belonging to the user was accepted. Patterns ② and ① meeting: trusting an id from the client, and a confirm step that never verified the selection step was valid.

06

How to defend

Layer 1

The server recomputes every value it can compute

mandatory

This is the fix for pattern ② and it has the best effect-per-effort ratio, because pattern ② is the most common.

The one-line rule: if the server can compute it, the server must not accept it from the client. The client sends productId and quantity; price, tax, shipping, discount amount and total are computed server-side. There is no price field in the input DTO — not "ignored if present", but absent, so a wrong binding cannot happen once.

This is also exactly the mass-assignment fix (see the access-control topic): the input DTO is separate from the entity, and contains only the fields the client genuinely gets to decide.

A quick self-check on any endpoint: read the input DTO and for each field ask "could the server know this value itself?". Every "yes" is a field to delete.

C# · Layer 1Server-computed price, domain in a value object, state in the aggregate, price frozen at checkout.
// ── Mẫu ② · DTO đầu vào chỉ có thứ client thật sự được quyết ────────────────//// UnitPrice KHÔNG có ở đây, và điều quan trọng là nó không tồn tại chứ không phải// bị bỏ qua: một trường "bị bỏ qua" chỉ cách một lần refactor với việc được bind lại.public record AddToCartRequest(string ProductId, int Quantity); // ── Mẫu ③ · Miền giá trị là một KIỂU, không phải một câu if ─────────────────/// <summary>/// Một Quantity không hợp lệ KHÔNG TỒN TẠI được trong domain. Đó là khác biệt giữa/// một phép kiểm (có thể quên gọi) và một bất biến (không có đường đi vòng).////// Trần cũng bắt buộc như sàn: 2_147_483_647 * unitPrice tràn int/decimal và cho ra/// một tổng nhỏ hoặc âm — cùng hậu quả với số âm, qua một cửa khác./// </summary>public readonly record struct Quantity{    public const int MaxPerLine = 100;     public int Value { get; }     private Quantity(int value) => Value = value;     public static Quantity Create(int value) =>        value is < 1 or > MaxPerLine            ? throw new ApplicationGeneralException(OrderErrorsList.INVALID_QUANTITY,                $"Quantity must be between 1 and {MaxPerLine}")            : new Quantity(value);} [HttpPost("/api/cart/items")]public async Task<IActionResult> AddItem([FromBody] AddToCartRequest req, CancellationToken ct){    var quantity = Quantity.Create(req.Quantity);   // ném lỗi ngay ở biên     // Giá do SERVER tra ra. Không có đường nào để client ảnh hưởng tới con số này.    var product = await _products.GetAsync(req.ProductId, ct)        ?? throw new NotFoundException(CatalogErrorsList.PRODUCT_NOT_FOUND);     var cart = await _carts.GetForUserAsync(_currentUser.UserId, ct);    cart.AddItem(product.Id, quantity, product.CurrentPrice);   // bất biến trong aggregate     await _carts.SaveAsync(cart, ct);    return Ok(new { cartTotal = cart.Total });} // ── Mẫu ① · Máy trạng thái tường minh, TRONG aggregate ─────────────────────public sealed class Order : AggregateRoot{    private static readonly Dictionary<OrderStatus, OrderStatus[]> Allowed = new()    {        [OrderStatus.Draft]     = [OrderStatus.AwaitingPayment, OrderStatus.Cancelled],        [OrderStatus.AwaitingPayment] = [OrderStatus.Paid, OrderStatus.Cancelled],        [OrderStatus.Paid]      = [OrderStatus.Shipped, OrderStatus.Refunded],        [OrderStatus.Shipped]   = [OrderStatus.Delivered, OrderStatus.Returned],        [OrderStatus.Delivered] = [OrderStatus.Returned],        [OrderStatus.Cancelled] = [],        [OrderStatus.Refunded]  = [],    };     public OrderStatus Status { get; private set; }     /// <summary>    /// Bất biến ở đây, không ở controller. Controller là MỘT đường vào — còn có job    /// nền, admin panel, webhook của cổng thanh toán, và một lệnh CLI dọn dữ liệu.    /// Một bất biến chỉ đúng ở một đường vào là một bất biến giả.    /// </summary>    private void TransitionTo(OrderStatus next, DateTime now)    {        if (!Allowed[Status].Contains(next))            throw new ApplicationGeneralException(OrderErrorsList.INVALID_TRANSITION,                $"Cannot go from {Status} to {next}");        Status = next;        UpdatedAt = now;    }     public void Ship(string trackingCode, DateTime now)    {        TransitionTo(OrderStatus.Shipped, now);   // Draft → Shipped bị từ chối ở đây        TrackingCode = trackingCode;    }     // ── Mẫu ⑤ · Đóng băng giá lúc checkout ─────────────────────────────────    /// <summary>    /// Sao chép giá và số lượng vào order_line BẤT BIẾN. Sau bước này, sửa giỏ hàng    /// không ảnh hưởng gì tới đơn — nên cửa sổ "đổi giá giữa checkout và capture"    /// không còn tồn tại.    ///    /// Và nó trả lời được câu "đơn này lúc mua giá bao nhiêu" sáu tháng sau, thứ mà    /// một đơn đọc lại giá từ bảng product không bao giờ trả lời đúng.    /// </summary>    public void Checkout(Cart cart, DateTime now)    {        if (cart.Items.Count == 0)            throw new ApplicationGeneralException(OrderErrorsList.EMPTY_CART);         _lines.Clear();        foreach (var item in cart.Items)            _lines.Add(OrderLine.Snapshot(item.ProductId, item.Quantity, item.UnitPriceAtAdd));         Total = _lines.Sum(l => l.Quantity.Value * l.UnitPrice);        TransitionTo(OrderStatus.AwaitingPayment, now);    }}
TypeScript · Layer 1Zod at the boundary, total recomputed from the DB, and status following the real charge result.
import { z } from "zod"; // Miền giá trị ở BIÊN. Không có trường total, không có trường price — server tính// cả hai. Client chỉ được quyết productId và quantity.const CheckoutBody = z.object({  items: z.array(z.object({    productId: z.string().min(1).max(64),    // Sàn VÀ trần. Thiếu trần thì Number.MAX_SAFE_INTEGER làm tổng mất chính xác.    quantity: z.number().int().min(1).max(100),  })).min(1).max(50),}); app.post("/api/checkout", async (req, res) => {  const parsed = CheckoutBody.safeParse(req.body);  if (!parsed.success) return res.status(422).json({ error: "invalid_body" });   // Tính lại tổng từ giá TRONG DB. Đây là dòng đóng mẫu ②.  const products = await db.products.findMany({    where: { id: { in: parsed.data.items.map((i) => i.productId) } },  });   const priceOf = new Map(products.map((p) => [p.id, p.currentPrice]));  let total = 0n;  for (const item of parsed.data.items) {    const price = priceOf.get(item.productId);    if (price === undefined) return res.status(404).json({ error: "product_not_found" });    // BigInt cho tiền, không float: 0.1 + 0.2 !== 0.3, và sai số tích lũy là một    // lỗi logic mà không ai gọi là lỗ hổng cho tới ngày đối soát.    total += BigInt(price) * BigInt(item.quantity);  }   // Đơn được tạo ở trạng thái chờ, KÈM tổng đã đóng băng — không phải trạng thái paid.  const order = await db.orders.create({    userId: req.user.id,    status: "awaiting_payment",    total: total.toString(),    lines: parsed.data.items.map((i) => ({ ...i, unitPrice: priceOf.get(i.productId)! })),  });   const charge = await stripe.charges.create({    amount: Number(total),    currency: "vnd",    idempotencyKey: `order-${order.id}`,   // retry không tạo lần trừ tiền thứ hai  });   // Trạng thái đi theo KẾT QUẢ THẬT, không theo việc lời gọi đã trả về.  if (charge.status !== "succeeded") {    await db.orders.update(order.id, { status: "payment_failed" });    return res.status(402).json({ error: "payment_failed" });  }   await db.orders.update(order.id, { status: "paid" });  return res.json({ orderId: order.id, total: total.toString() });});
Layer 1b

An explicit state machine — step N verifies step N−1

mandatory

The fix for pattern ①. The problem is not a missing [Authorize] — the /ship endpoint has [Authorize], and it still ships unpaid orders. The problem is that nothing represents "which step this order is at" except the order in which the UI happens to call the endpoints.

How: a status column with the legal transitions written out as code, and every action asking the aggregate rather than deciding for itself:

C#
public void Ship(DateTime now){    // Not an if in the controller — the invariant lives in the aggregate, so EVERY    // entry point goes through it, including a background job written later.    if (Status != OrderStatus.Paid)        throw new ApplicationGeneralException(            OrderErrorsList.INVALID_TRANSITION, $"Cannot ship an order in {Status}");    Status = OrderStatus.Shipped;}

The important part: the invariant lives in the aggregate, not the controller. The controller is one of several entry points (there are also background jobs, an admin panel, a payment webhook, a data-cleanup CLI), and an invariant enforced at one entry point is not an invariant.

Layer 1c

Value domains with a floor AND a ceiling, enforced by the type not an if

mandatory

The fix for pattern ③. Two halves, and the second is the one usually skipped:

  • The floor: quantity > 0, amount > 0, discountPercent between 0 and 100.
  • The ceiling: quantity <= 100. Without it, quantity = 2147483647 overflows an int on multiply, and the total comes out small or negative — the same outcome as a negative number, through a different door.

And where you enforce it decides whether it survives:

  • FluentValidation on the input DTO rejects at the boundary and gives a decent error message.
  • But the real invariant belongs in a value object or aggregate: Quantity.Create(n) throws for n ≤ 0, so an invalid Quantity cannot exist in the domain. That is the difference between a check and an invariant.
  • Plus a DB CHECK as the net, for the write path nobody considered (imports, migrations, jobs).

For money: decimal, never float. And a Money value object carrying its currency — adding two amounts in different currencies is a logic bug the type system can stop.

Layer 1d

Re-read and freeze state at the deciding step

The fix for pattern ⑤. The bug in the block 4 example is: the price is validated at checkout then re-read at capture, and between those two steps the client can edit the cart.

Two approaches, chosen by business need:

  • Freeze it: the checkout step copies price and quantity into an immutable order_line. capture then reads only order_line and never re-reads the cart. Editing the cart afterwards changes nothing. This is right for most e-commerce, and it is also the only way to answer "what did this cost at purchase time" six months later.
  • Re-read and compare: capture recomputes the total and compares it to the quoted one. Mismatch aborts. Correct when prices genuinely can change, but it needs a business decision about who absorbs the difference.

And for concurrency: lock the row or use optimistic concurrency (xmin/rowversion) so two flows cannot capture one order — see the race-conditions topic.

Layer 2

Business invariants as DB constraints and reconciliation queries

mandatory

This layer catches what layer 1 will miss: a new write path, a data import, a background job, or an endpoint written after this page existed.

SQL
-- Invariants written as constraints. The database does not forget and grants no exceptions.ALTER TABLE core.order_line ADD CONSTRAINT ck_qty_positive  CHECK (quantity BETWEEN 1 AND 100);ALTER TABLE core.order_line ADD CONSTRAINT ck_price_nonneg  CHECK (unit_price >= 0);ALTER TABLE core."order"    ADD CONSTRAINT ck_total_nonneg  CHECK (total >= 0); -- And a nightly reconciliation: every row returned is a broken invariant.SELECT o.id, o.total, sum(l.quantity * l.unit_price) AS computed  FROM core."order" o JOIN core.order_line l ON l.order_id = o.id GROUP BY o.id, o.totalHAVING o.total <> sum(l.quantity * l.unit_price);

The reconciliation query is the most important detection here, because logic flaws leave no error: no exception, no unusual log line, the data looks normal. The only thing that finds them is two numbers that should agree and do not.

Layer 3

Write the assumptions down at design time — the cheapest and earliest control

Block 2 argues most wrong assumptions are created while writing requirements. So the highest-yield control is not in the code: it is an "invariants" section in every feature document.

Three questions, asked before the first line of code:

  1. Which values here could the server compute itself? → pattern ②
  2. Which states are legal, and which step is reachable from which? → pattern ①
  3. Which values are out of domain — negative, zero, enormous, empty, duplicate? → patterns ③ and ④

Lightweight threat modeling (STRIDE, one page) over the money-touching flows is where those three get asked. See the threat-modeling topic. Layer 3 because it blocks nothing at runtime — but it is the only control acting on the cause, and an assumption written down at design time is roughly two orders of magnitude cheaper than an invariant discovered in production.

07

Verifying the fix

This is the topic where the verification method is a way of thinking, not a tool. There is no scanner here, and that is the point.

1. For each money-touching flow, write one test per pattern from block 3. Five patterns, five tests. The important part is that they are business negative tests, not validation tests: the assertion is not "returns 400" but "the money did not change" and "the order did not reach Shipped". See the csharp / test tab.

2. Grep for pattern ② in input DTOs — the only feasible automated check on this topic:

Shell
# A field the server can compute must not appear in an input DTO.grep -rnE 'record .*(Request|Command|Dto)\(' -A15 --include='*.cs' src/ \  | grep -iE 'decimal +(price|total|amount|discount|tax|fee)|bool +is(Admin|Paid|Verified)|Role +role' \  && { echo "input DTO carries a server-computable field — review it"; exit 1; }exit 0

3. Test the state machine as a complete table, not a few cases. With N states and M actions, write a [Theory] covering all N×M combinations and assert whether each cell is legal. This is the only way to catch the cell nobody thought about — and the cell nobody thought about is the definition of pattern ①.

4. The layer-2 reconciliation query belongs in CI, run against the data left by the integration tests. If some test leaves total disagreeing with sum(line), that is a logic bug, and this query is what sees it.

5. Check the DB constraints still exist — they live in migrations, so another migration can drop them:

SQL
SELECT conname FROM pg_constraint WHERE conrelid = 'core.order_line'::regclass AND contype = 'c';-- must include ck_qty_positive and ck_price_nonneg

6. And the check that cannot be automated: a person reads the flow and tries to go wrong. Nothing replaces it. But it has structure: walk the five patterns from block 3, one flow at a time, and write down the answers. Thirty minutes on a checkout flow finds more than every tool on this list.

C#Five patterns, five tests — asserting "the money did not change", not "returns 400".
public class CheckoutLogicTests : IClassFixture<ApiFixture>{    private readonly ApiFixture _fx;     public CheckoutLogicTests(ApiFixture fx) => _fx = fx;     /// <summary>    /// Mẫu ② — tin dữ liệu client. Test gửi unitPrice VÀO BODY dù DTO không có    /// trường đó: nếu ai đó thêm nó lại trong một lần refactor, test này đỏ.    ///    /// Khẳng định là GIÁ THẬT, không phải mã trạng thái: một bản vá trả 400 cũng    /// pass "Assert.BadRequest", nhưng chỉ bản vá đúng làm tổng bằng giá catalogue.    /// </summary>    [Fact]    public async Task Client_supplied_price_is_ignored_entirely()    {        await _fx.SeedProductAsync("iphone-16-pro", price: 32_000_000m);         var res = await _fx.ClientAs(_fx.Bob).PostAsJsonAsync("/api/cart/items", new        {            productId = "iphone-16-pro",            quantity = 1,            unitPrice = 1,          // trường không có trong DTO — cố tình gửi        });         res.EnsureSuccessStatusCode();        var cart = await res.Content.ReadFromJsonAsync<CartDto>();        Assert.Equal(32_000_000m, cart!.CartTotal);   // giá catalogue, không phải 1    }     /// <summary>    /// Mẫu ③ — miền giá trị. Cả sàn và TRẦN, và trần là nửa hay bị bỏ:    /// int.MaxValue tràn khi nhân và cho ra tổng âm mà không cần số âm nào.    /// </summary>    [Theory]    [InlineData(-3)]    [InlineData(0)]    [InlineData(101)]    [InlineData(int.MaxValue)]    public async Task Quantity_outside_its_domain_is_rejected(int quantity)    {        await _fx.SeedProductAsync("airpods", price: 2_990_000m);         var res = await _fx.ClientAs(_fx.Bob).PostAsJsonAsync("/api/cart/items", new        {            productId = "airpods", quantity,        });         Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);         // Và khẳng định thật sự quan trọng: giỏ hàng KHÔNG bị chạm tới. Một bản vá        // trả 400 sau khi đã ghi vào DB vẫn pass mọi khẳng định về status code.        var cart = await _fx.GetCartAsync(_fx.Bob);        Assert.Empty(cart.Items);        Assert.Equal(0m, cart.Total);    }     /// <summary>    /// Mẫu ① — bỏ bước. Bảng ĐẦY ĐỦ, không phải vài trường hợp: với N trạng thái và    /// M hành động, phủ hết N×M là cách duy nhất bắt được ô mà không ai nghĩ tới —    /// và "ô không ai nghĩ tới" chính là định nghĩa của mẫu này.    /// </summary>    [Theory]    [InlineData(OrderStatus.Draft,            "ship",   false)]    [InlineData(OrderStatus.AwaitingPayment,  "ship",   false)]    [InlineData(OrderStatus.Paid,             "ship",   true)]    [InlineData(OrderStatus.Shipped,          "ship",   false)]   // không giao hai lần    [InlineData(OrderStatus.Cancelled,        "ship",   false)]    [InlineData(OrderStatus.Refunded,         "ship",   false)]    [InlineData(OrderStatus.Draft,            "refund", false)]    [InlineData(OrderStatus.Paid,             "refund", true)]    [InlineData(OrderStatus.Refunded,         "refund", false)]   // không hoàn hai lần    public async Task Only_legal_transitions_are_accepted(        OrderStatus from, string action, bool shouldSucceed)    {        var order = await _fx.SeedOrderInStateAsync(_fx.Bob, from);         var res = await _fx.ClientAs(_fx.Bob).PostAsync($"/api/orders/{order.Id}/{action}", null);         if (shouldSucceed)            res.EnsureSuccessStatusCode();        else        {            Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);            // Trạng thái KHÔNG đổi — đây là khẳng định về nghiệp vụ, không về HTTP.            Assert.Equal(from, (await _fx.GetOrderAsync(order.Id)).Status);        }    }     /// <summary>    /// Mẫu ⑤ — giả định ngầm. Đây là test khó nghĩ ra nhất và đáng giá nhất: nó đi    /// đúng chuỗi thao tác hợp lệ mà không ai dự tính, và mọi request trong đó đều 200.    /// </summary>    [Fact]    public async Task Editing_the_cart_after_checkout_does_not_change_the_order()    {        await _fx.SeedProductAsync("iphone-16-pro", price: 32_000_000m);        var client = _fx.ClientAs(_fx.Bob);         await client.PostAsJsonAsync("/api/cart/items", new { productId = "iphone-16-pro", quantity = 10 });        var order = await (await client.PostAsync("/api/checkout", null))            .Content.ReadFromJsonAsync<OrderDto>();         // Hợp lệ hoàn toàn: người dùng sửa giỏ hàng. Và đó là điểm — nó KHÔNG được        // ảnh hưởng tới một đơn đã checkout.        await client.PatchAsJsonAsync("/api/cart/items/1", new { quantity = 1 });         var after = await _fx.GetOrderAsync(order!.Id);        Assert.Equal(320_000_000m, after.Total);           // 10 máy, giá đã đóng băng        Assert.Equal(10, after.Lines.Single().Quantity);    }     /// <summary>    /// Mẫu ④ — lặp quá số lần. Xem topic race-conditions cho phiên bản ĐỒNG THỜI;    /// đây là phiên bản tuần tự, và nó bắt được lỗi "không ai đếm".    /// </summary>    [Fact]    public async Task Same_coupon_cannot_be_applied_twice_to_one_order()    {        var order = await _fx.SeedOrderInStateAsync(_fx.Bob, OrderStatus.Draft, total: 10_000_000m);        await _fx.SeedCouponAsync("SAVE50", percent: 50, maxUses: 1);        var client = _fx.ClientAs(_fx.Bob);         var first = await client.PostAsJsonAsync($"/api/orders/{order.Id}/coupon", new { code = "SAVE50" });        var second = await client.PostAsJsonAsync($"/api/orders/{order.Id}/coupon", new { code = "SAVE50" });         first.EnsureSuccessStatusCode();        Assert.Equal(HttpStatusCode.UnprocessableEntity, second.StatusCode);        Assert.Equal(5_000_000m, (await _fx.GetOrderAsync(order.Id)).Total);   // giảm MỘT lần    }     /// <summary>    /// Bất biến toàn cục, chạy sau mọi test khác: total của đơn luôn khớp tổng các    /// dòng. Đây là query đối soát ở lớp 2, đặt vào CI — và nó bắt được lỗi logic mà    /// không test nào ở trên nghĩ tới, vì lỗi logic không để lại exception nào.    /// </summary>    [Fact]    public async Task Order_totals_always_match_the_sum_of_their_lines()    {        var mismatched = await _fx.QueryAsync<(long Id, decimal Total, decimal Computed)>("""            SELECT o.id, o.total, sum(l.quantity * l.unit_price)              FROM core."order" o JOIN core.order_line l ON l.order_id = o.id             GROUP BY o.id, o.total            HAVING o.total <> sum(l.quantity * l.unit_price)            """);         Assert.Empty(mismatched);    }}
08

Common mistakes

The "fix"Why it is wrong
Add quantity > 0 validation on the reported endpointCloses one cell. discountPercent, amount, points and the ceiling are all still missing
Enforce invariants in the controllerThe controller is ONE entry point. Background jobs, the admin panel, the payment webhook and the cleanup CLI do not pass through it
Ignore the price field if the client sends itThe field is still in the DTO, so one refactor that binds it again is enough. It must NOT EXIST
A floor with no ceilingquantity = 2147483647 overflows an int on multiply and yields a small or negative total — same outcome, different door
Validate the price at checkout, re-read it at captureThat is pattern ⑤. The client edits the cart in between
Use float for money0.1 + 0.2 != 0.3. Accumulated rounding error is a logic bug nobody calls a vulnerability until reconciliation day
Hide the button in the UIThe UI is not where rules are enforced. The attacker calls the API
Rely on a scannerA scanner would have to know which outcome is CORRECT. It does not know your business

The biggest mistake on this topic is classification: treating logic flaws as "bugs" rather than "vulnerabilities". They land in the product backlog at medium priority instead of the security incident process — while their financial impact is usually larger than an XSS.

The scoping mistake: fixing the reported variant. If a negative quantity got through, then every numeric field across the whole API needs the same question asked — because the cause was not a missing line, it was that nobody had written the value domain down.

09

References

Tier 1A04:2021 – Insecure Design · OWASP · Top 10 · 2021
Tier 1API6:2023 Unrestricted Access to Sensitive Business Flows · OWASP · API Security Top 10 · 2023
Tier 1CWE-840: Business Logic Errors · MITRE · CWE · 4.14
Tier 2Business logic vulnerabilities · PortSwigger · Web Security Academy
Tier 2Threat Modeling Cheat Sheet · OWASP · Cheat Sheet Series
Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.

Sign upSign in

You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.

Loading comments…