AuditCoreAuditCore
highCWE-840OWASP A04:2021 — Insecure Design

How to fix Business Logic Abuse

Your scanner found 0 SQL injections. Your code review caught 0 XSS. Your app is still bleeding money because attackers send `price=0` to your checkout endpoint and your server happily processes it. Business logic abuse is the OWASP A04 family — flaws in workflow design, not in input validation.

What is Business Logic Abuse?

Business logic abuse exploits the difference between 'what your code accepts' and 'what your business rules allow'. The classic: an e-commerce checkout where the client sends the cart contents AND the price. Attacker modifies the price field in transit (or via a tampered API call) to $0.01 — server accepts because the request is technically valid (correct format, correct types, correct auth). Money lost.

Variants are infinite because they depend on your specific business rules: skipping payment steps (POST /order/confirm without going through /payment), modifying order status (PATCH /order/123 with status='paid'), abusing coupons (apply same coupon 100x), changing currency (USD→VND for 25,000x discount), inventory race conditions (place 10 orders for the last item simultaneously), trial extension (DELETE /trial then re-signup), referral fraud (referring yourself).

Why traditional scanners miss these: they test for INPUT validation, not WORKFLOW validity. A scanner has no idea your order should not be marked 'paid' until Stripe webhook confirms — that's domain knowledge. AuditCore's Business Logic Scanner tests common patterns (price=0, status=paid, etc.) but the most damaging logic flaws are app-specific and need manual threat modeling.

What an attacker can do

The concrete impact of leaving Business Logic Abuse unpatched.

Direct revenue loss

Free products, free trials forever, unauthorized currency conversions — money out the door per request.

Inventory drain via race conditions

Concurrent orders for limited stock; without proper locking, oversell.

Privilege escalation via status tampering

PATCH user role from 'free' to 'admin'. Auth allows the request; logic should reject the field change.

Compliance / fiduciary breach

Refunds processed without approval, transactions backdated, audit trail gaps — regulatory issues for fintech / healthcare.

How do I know if I'm vulnerable?

Manual: threat-model your critical workflows. For each: what's the happy path? What state changes happen? Who's authorized to trigger them? What if the user sends fields you don't expect (price, status, role)? What if they send the same request twice? What if they skip a step?

Automated: AuditCore's Business Logic Scanner tries standard attacks — price=0, quantity=-1, currency=VND, role=admin, status=paid — against discovered checkout/profile endpoints. The Smart API Fuzzer generates schema-driven attacks from OpenAPI specs. The Race Condition Tester sends 20 concurrent requests to the same endpoint to find ordering bugs.

How to fix Business Logic Abuse

5 ordered steps. Apply them in order — each builds on the previous.

  1. 1

    Server-authoritative pricing — never trust client price

    Client sends item IDs and quantities. Server looks up prices from database, calculates total. Never accept price as input.

    Sounds obvious; it isn't. Many React-style apps pass full cart objects (with prices) as the source of truth. Don't.

    javascriptStep 1
    // ❌ Trusting client price
    app.post("/checkout", async (req, res) => {
      const { items } = req.body;  // [{id, qty, price}]
      const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
      await stripe.charges.create({ amount: total * 100, ... });
    });
    
    // ✅ Server-authoritative price
    app.post("/checkout", async (req, res) => {
      const { items } = req.body;  // [{id, qty}]
      const products = await Product.find({
        _id: { $in: items.map(i => i.id) }
      });
      const productMap = new Map(products.map(p => [p.id, p]));
    
      let total = 0;
      for (const item of items) {
        const p = productMap.get(item.id);
        if (!p || !p.active) return res.status(400).send("Invalid item");
        if (item.qty <= 0 || item.qty > 100) return res.status(400).send("Invalid qty");
        total += p.price * item.qty;
      }
      await stripe.charges.create({ amount: total * 100, ... });
    });
  2. 2

    Allow-list updatable fields (no mass assignment)

    When users PATCH a resource, accept only explicitly allowed fields. Never spread req.body into your model.

    Mass assignment is the source of 'PATCH /users/me with role=admin' privilege escalation.

    pythonStep 2
    # ❌ Mass assignment — user can set ANY field
    @app.patch("/users/me")
    def update_me(updates: dict):
        user = current_user()
        for k, v in updates.items():
            setattr(user, k, v)  # role=admin, balance=999999, ...
        user.save()
    
    # ✅ Explicit allow-list
    ALLOWED = {"name", "email", "avatar_url", "timezone"}
    
    @app.patch("/users/me")
    def update_me(updates: dict):
        user = current_user()
        for k, v in updates.items():
            if k not in ALLOWED:
                continue  # silently drop, or return 400
            setattr(user, k, v)
        user.save()
    
    # Better: use a Pydantic schema with strict fields
    class UserUpdate(BaseModel):
        model_config = {"extra": "forbid"}
        name: Optional[str] = None
        email: Optional[EmailStr] = None
        avatar_url: Optional[HttpUrl] = None
        timezone: Optional[str] = None
  3. 3

    Use server-side state machines for workflows

    Define explicit state transitions. Order can go pending → paid → fulfilled. Reject any other transition.

    Don't accept arbitrary status updates. Status changes should happen as a side-effect of business events (Stripe webhook = transition to paid; warehouse API = transition to fulfilled).

    javascriptStep 3
    const VALID_TRANSITIONS = {
      pending: ["paid", "cancelled"],
      paid: ["fulfilled", "refunded"],
      fulfilled: ["returned"],
      cancelled: [],
      refunded: [],
      returned: [],
    };
    
    async function transitionOrder(orderId, newStatus, source) {
      const order = await Order.findById(orderId);
      const allowed = VALID_TRANSITIONS[order.status] || [];
      if (!allowed.includes(newStatus)) {
        throw new Error(`Cannot transition ${order.status} → ${newStatus}`);
      }
      // Plus: only certain sources can trigger certain transitions
      if (newStatus === "paid" && source !== "stripe_webhook") {
        throw new Error("Only Stripe can mark orders paid");
      }
      order.status = newStatus;
      await order.save();
    }
    
    // API endpoints don't directly accept status changes
    // app.patch("/orders/:id") rejects any status field
  4. 4

    Idempotency keys for money-affecting operations

    If the user submits the same checkout twice (double-click, network retry), don't charge twice. Require Idempotency-Key header.

    Stripe and most payment APIs support idempotency natively. Use the same pattern internally for refund, transfer, charge endpoints.

    javascriptStep 4
    // Express middleware — require + verify idempotency key
    async function idempotency(req, res, next) {
      const key = req.get("Idempotency-Key");
      if (!key || !/^[a-f0-9-]{32,64}$/.test(key)) {
        return res.status(400).json({ error: "Idempotency-Key required" });
      }
    
      const cached = await redis.get(`idem:${key}`);
      if (cached) {
        return res.status(200).json(JSON.parse(cached));
      }
    
      // Capture response
      const orig = res.json.bind(res);
      res.json = (body) => {
        redis.setex(`idem:${key}`, 86400, JSON.stringify(body));
        return orig(body);
      };
      next();
    }
    
    app.post("/charge", idempotency, chargeHandler);
  5. 5

    Rate-limit business actions per user/account

    Coupon abuse, referral fraud, trial extension — all involve repeated actions. Per-user limits stop them.

    Different from per-IP rate limiting (which catches DoS). This is per-account: 'one trial per user', 'one referral redemption per user', 'max 5 coupon uses per day'.

    pythonStep 5
    from datetime import datetime, timedelta
    
    def can_redeem_coupon(user_id: int, coupon_id: int) -> bool:
        # Per-user, per-coupon: only once
        existing = CouponRedemption.query.filter_by(
            user_id=user_id, coupon_id=coupon_id
        ).first()
        if existing:
            return False
        # Daily cap — per user, max 5 coupons / day
        today_count = CouponRedemption.query.filter(
            CouponRedemption.user_id == user_id,
            CouponRedemption.redeemed_at >= datetime.utcnow() - timedelta(days=1)
        ).count()
        return today_count < 5
    
    def redeem(user_id: int, coupon_id: int):
        if not can_redeem_coupon(user_id, coupon_id):
            raise ValueError("Coupon limit reached")
        db.session.add(CouponRedemption(user_id=user_id, coupon_id=coupon_id))
        db.session.commit()

How to verify the fix

AuditCore's Business Logic Scanner tries `price=0`, `quantity=-1`, `status=paid`, `role=admin` against checkout/order/profile endpoints discovered by the crawler. Findings include the payload that worked.

Manual: open dev tools, intercept your checkout. Modify the price field to 0.01. Submit. Did Stripe charge $0.01? Bug. Try cancelling an order — does the refund go through without admin approval? Try modifying your role via PATCH /users/me. Try clicking 'Buy Now' five times rapidly on a limited-stock item.

FAQ

Frequently asked questions

Why don't normal scanners catch business logic bugs?+

Scanners test syntax and known vulnerability patterns. Business logic bugs are domain-specific — your scanner has no idea what 'order paid' should require. AuditCore's Business Logic Scanner tests common patterns; full coverage needs threat modeling specific to your app.

Is OWASP A04 (Insecure Design) the same thing?+

It's the umbrella category. Business logic abuse is the most common flavor of insecure design — designs that don't account for adversarial input.

Can I write tests for business logic security?+

Yes — and you should. Property-based tests (hypothesis, fast-check) excel here. 'For any cart, the charged amount equals the sum of (server-side price × quantity).' 'For any user, no PATCH /users/me request can change role.' These catch regressions automatically.

What about race conditions specifically?+

Covered separately — see /fix/race-conditions. Most BLA fixes assume sequential execution; race conditions break that assumption. Use database transactions, optimistic locking, or row-level locks for inventory and balance updates.

How do I find all business logic flaws in my app?+

Threat-model each workflow. For 'place order': what state changes? Who can trigger them? What if the request is repeated, modified, skipped, sent in a different order? Document the attack tree, write tests for each branch. Tedious but it's the only way.

Don't just guess — scan and verify

AuditCore Free Trial scans your homepage for Business Logic Abuse and 50+ other vulnerability classes. No credit card. Results in 60 seconds.