One Code, One Client, Many Freebies
"Once per customer" sounds like a rule the database enforces. In a lot of billing code it's really a rule a SELECT COUNT(*) politely asks about — and if you ask the same question from ten tabs at once, you get ten yeses.
The rule, and the gap under it
FOSSBilling lets an operator mark a promo code as once_per_client: each client account can redeem it at most once. When a client checks out, the code re-checks eligibility by counting how many times that client has already redeemed the promo. Zero redemptions means "go ahead." That check lives in Cart\Service::checkoutCart() and bottoms out in a COUNT query.
The problem is when the matching write happens. The redemption row that records "this client used this code" isn't inserted at the moment of the check — it's written much later, at the end of the checkout, in Product\Service::createCheckoutPromoRedemptions(), with an ordinary persist. Nothing in between takes a lock, and there's no unique constraint on the table. The count-check and the redemption-write are two separate moments with the entire checkout sitting between them.
Racing the window
This is a textbook time-of-check-to-time-of-use (TOCTOU) race, and a comfortable one to exploit because the window is wide. You don't need to hit a microsecond gap; you need several checkout requests in flight together. I drove it with a threading.Barrier so a batch of checkouts all release at the same instant:
def checkout():
barrier.wait() # everybody goes at once
requests.post(f"{TARGET}/api/client/cart/checkout",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"promocode": PROMO})
Every request in the batch reads a redemption count of zero, every request passes the eligibility check, and every request goes on to create its own discounted order. One client, one code, as many redemptions as the batch size.
Why it only rates Moderate — and why it still matters
I want to be honest about severity. Promos in FOSSBilling are discount-only, and a zero-total order creates no invoice and no balance, so the blast radius is free or underpriced provisioning — not account takeover, not data loss. That caps it at Moderate. But it's a real money bug: a 100%-off code meant to onboard a single trial client can instead hand that one client a stack of free services, and a promo with a finite global cap can be drained entirely by one person before anyone else sees it.
What makes it interesting to me is that the project had already learned this lesson once. The global usage limit was made atomic back in 0.8.0 with a conditional increment. The per-client check was the same shape of bug sitting right next to the fixed one, and it never got the same treatment — a good reminder that when you fix a race, it's worth grepping for its siblings. The maintainers closed this one in 0.8.9.
COUNT query, the full attack chain, the threaded PoC, and the disclosure timeline.