Start learning free

LESSON 15 OF 20 · SECURITY: INTERVIEW QUESTIONS

One Coupon, Used Twice

Practice

An interviewer shows you the shop's coupon route and asks: "Can a visitor use one coupon twice?"

row = db.execute("SELECT discount, used FROM coupons WHERE code = ?", (code,)).fetchone()
if row is None:
    return jsonify({"error": "No such coupon"}), 404
if row["used"] == 1:
    return jsonify({"error": "Coupon already used"}), 409
query = (
    "UPDATE coupons SET used = 1 "
    "WHERE code = ? "
)
cursor = db.execute(query, (code,))
db.commit()
return jsonify({"code": code, "discount": row["discount"]})

Yes. The route reads the coupon, checks it and marks it in separate steps. If an attacker sends two requests at the same moment, both read used = 0 before either one marks it, so both pass the check. This is a race condition: the attacker gets one coupon's discount twice, or as many times as requests fit in the gap.

The fix is to let the database check and mark in one statement, then ask how many rows it changed with cursor.rowcount. Only one request can turn 0 into 1; every other one changes 0 rows. Here is the same idea for a gift card that can be spent once:

cursor = db.execute("UPDATE gift_cards SET spent = 1 WHERE id = ? AND spent = 0", (card_id,))
db.commit()
if cursor.rowcount == 0:
    return jsonify({"error": "Card already spent"}), 409

The check sends these requests. With the header X-Test-Rival, a hidden second request marks the coupon just before your route's UPDATE runs, like the attacker's twin request. Right now that request gets 200 and the discount: that is the hole.

POST /coupons/SPRING10/use                          →  200  {"code": "SPRING10", "discount": 10}
POST /coupons/SPRING10/use                          →  409  {"error": "Coupon already used"}
POST /coupons/WELCOME5/use  X-Test-Rival: WELCOME5  →  409  {"error": "Coupon already used"}
POST /coupons/WELCOME5/use                          →  409  {"error": "Coupon already used"}
POST /coupons/NOPE99/use                            →  404  {"error": "No such coupon"}
POST /coupons/VIP20/use                             →  200  {"code": "VIP20", "discount": 20}

Your task: in use_coupon, make the UPDATE also require used = 0, and answer 409 with "Coupon already used" when it changed no row.


Assignment
Follow the Coding Tutorial to make the coupon's check and mark one statement, and to turn away the request that loses the race.


Hint
The query is built from string pieces that each end with a space: add the new piece below the WHERE piece. After db.commit(), cursor.rowcount == 0 means another request marked the coupon first.


STEP 1 OF 3
    Ctrl + Enter to check