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:
- What are we working on? Sketch the data flow: who sends what, where it's stored, which services are involved.
- What can go wrong? Walk through STRIDE (below) for each arrow and box.
- What are we going to do about it? Decide a mitigation, accept the risk, or change the design.
- Did we do a good job? Revisit after launch and after incidents.
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.
protection:
20.4 Never trust the client with business rules
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