Promo Abuse Behind a WAF That Was Doing Its Job
Northwind Commerce runs a multi-tenant checkout API behind a CDN and a managed WAF. Finance found $18,400 of discounts that should not exist: 12 single-use promo codes were redeemed 400 times in 72 hours. The endpoint POST /api/v1/cart/coupon applies a code and POST /api/v1/orders finalizes it. Two facts emerged in triage. First, the client sends an Idempotency-Key header on order creation, but the backend only logs it - deduplication was never implemented. Second, the WAF rule rate-limit-coupon allows 10 requests per minute per source IP, and the attacker's traffic came from a residential proxy pool of roughly 3,000 addresses. The application uses a single Postgres primary; coupon usage is tracked in a uses_count column that is read at apply time and written at order creation. Separately, a mobile client posts JSON to the same order endpoint with Content-Type: text/plain, which the API accepts for legacy reasons, and a subset of routes still authenticate from the session cookie.
1. Explain why the promo abuse succeeded and identify the single server-side change that would have prevented it.
Read the worked answer
The abuse is a race condition plus a missing idempotency guarantee, not a volume problem. uses_count is read at apply time and written at order creation, so N concurrent requests all read uses_count = 0 and all pass the check; the later writes simply overwrite each other. The Idempotency-Key is logged but never enforced, so retries and replays create sibling orders that each carry the discount.
The WAF never engaged because it meters 10 requests per minute per source IP, and the attacker held each address to two or three requests. With 3,000 proxies the effective ceiling is thousands of discount attempts per minute. Per-IP rate limits bound one client's bandwidth; they do not bound a business invariant.
The durable fix moves the invariant into the database: a single conditional statement, UPDATE coupons SET uses = uses + 1 WHERE code = $1 AND uses < max_uses AND expires_at > now() RETURNING id, executed inside the same transaction that creates the order, plus a unique constraint on (coupon_id, order_id). SELECT ... FOR UPDATE or a distributed lock also works but is easier to get wrong under retries and partial failures. Independently, enforce the idempotency key with a unique index and replay the stored response.
2. Does accepting `text/plain` JSON on the session-cookie routes create a CSRF issue? Walk through what would have to be true for the exploit to be real.
Read the worked answer
Treat it as conditional until you confirm three things. A cross-origin HTML form can only send application/x-www-form-urlencoded, multipart/form-data, or text/plain. So accepting text/plain at a JSON endpoint removes the assumption that a browser cannot forge a well-formed body. The classic construction is a form field whose name is the JSON prefix, for example name {"coupon":"X","order":" with value "}, relying on text/plain joining name and value with =; the stray = lands inside a string or is ignored by a lenient parser. That gives you the forged request.
Two preconditions decide whether it is exploitable. (a) The route authenticates from the session cookie, and that cookie is not SameSite=Lax or Strict - Lax blocks cookies on cross-site POST, which kills the attack. (b) No CSRF token, Origin, or Sec-Fetch-Site check guards the route. Verify the cookie attributes and whether the legacy mobile handler shares the same code path as the web handler; a shared handler means the mobile relaxation silently widened the web attack surface.
Impact is attacker-chosen coupon application or order placement under the victim's account - smaller than account takeover, but still a transaction-integrity bug. The fix is cheap: reject text/plain at JSON routes, require a header forms cannot set, and set the cookie to SameSite=Lax. Attempt any of that before you attempt a token rollout.
3. You have one sprint. Rank the coupon race condition and the potential CSRF by what you would fix first, and say what metric you would stop reporting.
Read the worked answer
Rank by expected loss, not by severity score. The coupon abuse is already producing verified loss at $18,400 per 72 hours, with an adversary who has infrastructure and a working playbook; it is a live incident. The atomic-update change is a one-line SQL edit plus a migration and is deployable in hours. The CSRF path is speculative until you check cookie attributes and handler sharing, but that check takes ten minutes and the remediation is config-level, so it lands in the same sprint.
Both outrank broad hardening work. The trap in this scenario is treating WAF alert volume as a signal: rate-limit-coupon will keep generating tickets forever and never stop this class of abuse. Tune or retire it and instrument the actual invariant instead - alert on any row where uses > max_uses, and alert on order-creation failures from the new unique constraint, since those are exactly the signature of a retry storm or an in-progress race. Also add a per-coupon and per-account velocity alert, which is account-centric and survives proxy rotation in a way IP counters never will.