guest@websec: ~/websec/20-insecure-design — zsh — 100×40 by SaKKo ENTH code:

guest@websec ~ % man 20-insecure-design

20 · Insecure Design and Business Logic

Some bugs aren't coding mistakes. The code does exactly what the spec said, but nobody asked "what would an attacker do?". No scanner finds these.

20.1 Design flaws vs implementation bugs

The design itself is unsafe, even if the code is perfect:

  • The cart trusts the price sent by the browser.
  • Coupons can be stacked, or reused by sending the same request twice at once.
  • Quantity can be negative, turning a purchase into a refund.
  • A multi-step checkout lets you call "confirm" without "pay".
  • An expensive operation (report export, SMS sending) has no per-user limit.

20.2 Threat modeling in 20 minutes

Sounds heavy, but it's just four questions for any feature kickoff:

  1. What are we working on? Sketch the data flow: who sends what, where it's stored, which services are involved.
  2. What can go wrong? Walk through STRIDE (below) for each arrow and box.
  3. What are we going to do about it? Decide a mitigation, accept the risk, or change the design.
  4. Did we do a good job? Revisit after launch and after incidents.
sim: STRIDE a featurepick a feature, reveal threats

Write abuse cases next to user stories: "As an attacker, I want to use the same coupon 50 times." Each one becomes a test.

20.3 Race conditions

"Is the coupon unused? Then use it" has a gap between check and act. Fire 20 requests at once and several slip through. Gift cards get redeemed twice, balances overdrawn.

sim: concurrent redemptionsfire N requests at once
protection:

20.4 Never trust the client with business rules

sim: checkout requestedit what the browser sends

order the server creates

All on us. Use row locks (with_lock), atomic updates, unique indexes and DB check constraints. Model validations alone can race.
# atomic: only one request can flip used from false to true
claimed = Coupon.where(code: params[:code], used: false).update_all(used: true, used_by_id: Current.user.id)
return render(status: :conflict, plain: "Already used") if claimed.zero?

# or lock the row for the whole check-and-act
coupon.with_lock do
  raise AlreadyUsed if coupon.used?
  coupon.update!(used: true)
  Current.user.credit!(coupon.amount)
end

# database as the final guard
add_index :redemptions, [:coupon_id], unique: true
add_check_constraint :order_items, "qty BETWEEN 1 AND 10", name: "qty_range"

# never take prices from the request
item.price = Product.find_by!(sku: line[:sku]).price