Coupon stacking lets checkout drop a €666 item to €0
Two ordinary promo codes, one missing server-side bound, and a checkout that treats a fully discounted vault as affordable.
midnight-vaultImpact: the Midnight Collection costs €666.00, but stacking the available promotions reduces the total to €0.00. Checkout accepts the order against the shop's €66.00 store credit and reveals the flag on the receipt.
The setup
The Halloween Store starts with €66.00 in store credit. Checkout is allowed when the cart total is less than or equal to the available credit. Two public promotions are available:
| Code | Source | Discount |
|---|---|---|
NEWS30 | Newsletter signup (POST /api/newsletter) | 30% |
NIGHT20 | Site banner | 20% |
The cart API tracks a count for each code and adds percentage × count to the discount. The combined discount is not capped at 100%, and the one-use limit is not consistently enforced.
From €666 to €0
Apply each promotion twice: NEWS30 contributes 60%, and NIGHT20 contributes 40%.
| Promotion | Calculation | Discount |
|---|---|---|
NEWS30 | €666 × 30% × 2 | −€399.60 |
NIGHT20 | €666 × 20% × 2 | −€266.40 |
| Total discount | €399.60 + €266.40 | −€666.00 |
The payable total becomes €0.00, so it passes the €66.00-credit checkout check.
The “already applied” response
A repeated POST /api/cart/coupon with {"code":"NEWS30"} can return HTTP 409:
HTTP/1.1 409 Conflict
Content-Type: application/json
{"ok":false,"error":"coupon_already_applied","message":"Coupon already applied."}
That response did not reliably prevent the later cart from showing the repeated applications. The observed cart showed:
NEWS30: 30% × 2 = €399.60 offNIGHT20: 20% × 2 = €266.40 off- Subtotal €666.00; complimentary delivery; total €0.00
- Available store credit €66.00
Reproduction
Keep the same session cookie across all requests:
- Add the item with
POST /api/cart/itemsand body{"productId":"midnight-vault"}. - Apply
NEWS30twice withPOST /api/cart/couponand body{"code":"NEWS30"}. - Apply
NIGHT20twice withPOST /api/cart/couponand body{"code":"NIGHT20"}. - Check
GET /cartfor both promotions at count 2 and a €0.00 total. - Submit
POST /api/checkoutwith body{}.
Checkout succeeds. The confirmation modal shows the flag under “Inside the archive”:
INTIGRITI{094638e6-ba2f-4c50-8bb4-41ced0d5debd}
Root cause
The discount calculation effectively subtracts price × (percent / 100) × count for each promotion. There is no upper bound on the accumulated discount, and the count can exceed one even when the client receives an “already applied” response.
Checkout then trusts the calculated cart total and checks only whether it is within the available credit. The €666 item therefore passes with a €0.00 payable total and €66.00 in credit.
What should be fixed
- Enforce coupon eligibility and usage limits on the server, atomically with the cart update.
- Recalculate discounts from validated promotion records rather than trusting a client-visible count.
- Cap the combined discount at the allowed maximum, normally the eligible subtotal.
- Recompute and validate the final payable amount at checkout using integer minor currency units.
A rejected response is not a security control unless the server also preserves the invariant in its state and enforces it again at checkout.