{"schema":"threatline.record/2","record":{"id":"GHSA-pq5m-7g9w-ppg8","aliases":["GHSA-pq5m-7g9w-ppg8"],"summary":" Coupon usage limit bypass: a single-use coupon can be redeemed an unbounded number of times, and cancelled orders mint fresh usages","details":"### Summary\r\n\r\nThelia 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.\r\n\r\nFirst, 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.\r\n\r\nSecond, 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.\r\n\r\n### Details\r\n\r\nVerified in the pristine `repo/` tree at commit `7668f3d`, and observed live\r\non tag 3.2.1. File references below are `repo/` paths with line numbers.\r\n\r\nThe limit is enforced in exactly one place, at apply and placement time.\r\n`core/lib/Thelia/Domain/Promotion/Coupon/CouponFactory.php:79-88` throws\r\n`CouponNoUsageLeftException` when `getUsagesLeft()` returns zero or less.\r\n`CouponManager::getSessionCoupons()` calls this on every cart change and order\r\nplacement, and `reconcileBeforeOrder` (on `ORDER_PAY`, priority 200) rebuilds\r\nthe coupons right before the order is written, so an exhausted code is dropped\r\nfrom carts. All of that only protects orders placed after exhaustion.\r\n\r\nCounting happens later, on a different event.\r\n`core/lib/Thelia/Action/Coupon.php:369-449` (`afterOrder`, on\r\n`ORDER_BEFORE_PAYMENT`) only remembers the coupon on the order as an\r\n`order_coupon` row with `usage_canceled = 1`. The code comment says its usage\r\nis not counted yet and that counting happens when the order is paid.\r\n\r\n`core/lib/Thelia/Action/Coupon.php:460-470` (`orderStatusChange`, on\r\n`ORDER_UPDATE_STATUS`) calls `consume()` when the order becomes paid and\r\n`release()` otherwise. Nothing on this path checks the usage limit.\r\n\r\n`core/lib/Thelia/Domain/Promotion/Coupon/Service/OrderCouponUsageManager.php:42-57`\r\n(`consume`) calls `decrementQuantity` for each remembered coupon and then\r\nunconditionally flips `usage_canceled` to false, even when the decrement did\r\nnothing.\r\n\r\n`core/lib/Thelia/Domain/Promotion/Coupon/Service/CouponManager.php:511-562`\r\n(`decrementQuantity`) reads `getUsagesLeft()` and skips the whole body when it\r\nis already zero, logs the event, and returns false. The caller ignores the\r\nreturn value. The paid order keeps the discount that was priced into it at\r\nplacement.\r\n\r\n`core/lib/Thelia/Model/Coupon.php:284-299` (`getUsagesLeft`) is a pure read of\r\n`max_usage` minus the per-customer count. The write is read-then-write\r\n(`setMaxUsage(--$usageLeft)`) with no lock and no atomic update.\r\n\r\nThe mint comes from the refund path.\r\n`core/lib/Thelia/Domain/Promotion/Coupon/Service/OrderCouponUsageManager.php:64-79`\r\n(`release`) refunds every row marked counted, including rows whose consume\r\nstep skipped an exhausted coupon. `CouponManager.php:567-610`\r\n(`incrementQuantity`) performs an uncapped `setMaxUsage(++$usageLeft)`.\r\nCancelling a phantom-counted order therefore inflates the counter past its\r\nconfigured maximum. Cancelling an unpaid order changes nothing, because\r\n`release` only selects counted rows, which confirms the mint is specific to\r\nthe phantom path.\r\n\r\n\r\n### Proof of concept\r\n\r\nLab: tag 3.2.1 on `http://127.0.0.1:8081` with MariaDB on `127.0.0.1:3307`,\r\ndemo catalogue installed. Merchant coupon created the way the back office\r\ncreates one: code `RACE10`, 5 EUR off, `max_usage = 1`, MatchForEveryone\r\ncondition. Each step below is one command. Row ids vary between runs.\r\n\r\nStep 1. Create the merchant coupon.\r\n\r\n```sh\r\ncd 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\";'\r\n```\r\n\r\nObserved: a new coupon row with code RACE10 and max_usage 1. In the recorded\r\nrun its id was 7.\r\n\r\nStep 2. Create the attacker customer through the admin API.\r\n\r\n```sh\r\ncurl -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\"}'\r\n```\r\n\r\nObserved: HTTP 201 with the new customer. `$ATOKEN` is an admin JWT from\r\n`POST /api/admin/login`.\r\n\r\nStep 3. Log in as that customer on the front API and save the token.\r\n\r\n```sh\r\ncurl -s -X POST http://127.0.0.1:8081/api/front/login -H 'Content-Type: application/json' -d '{\"username\":\"racer@example.com\",\"password\":\"RacerPass1!\"}'\r\n```\r\n\r\nObserved: HTTP 200 with a customer JWT. All following customer requests use\r\nit as `$FTOKEN`.\r\n\r\nStep 4. Create a delivery address for the customer.\r\n\r\n```sh\r\ncurl -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}'\r\n```\r\n\r\nObserved: HTTP 201 with the new address. In the recorded run its id was 17.\r\n\r\nStep 5. Run the stacking and minting flow. The runner boots the lab kernel\r\nand drives the exact production event flow of sequential cheque checkouts:\r\n`COUPON_CONSUME` (shopper types the code) to `ORDER_PAY`\r\n(`Action\\Order::create`, which raises `ORDER_BEFORE_PAYMENT` and therefore\r\n`afterOrder`) to `ORDER_UPDATE_STATUS` paid (therefore `orderStatusChange`\r\nand `consume`), then cancelled for the mint. Checkout address and module\r\nselections for each cart are performed over real HTTP\r\n(`POST /api/front/account/checkout/{cartId}/...` as the customer). No\r\nproduction code path is reimplemented. Only row setup is done directly.\r\n\r\n```sh\r\ncd live/app && ../../.tools/php /tmp/opencode/coupon_mint.php\r\n```\r\n\r\nObserved: two unpaid orders placed while the coupon showed one usage left,\r\nboth marked paid with the full 5 EUR discount while the counter moved 1 to 0\r\nto 0; cancelling the second order moved the counter 0 to 1; a third order\r\nplaced and paid on the minted usage moved it 1 to 0. Full log:\r\n\r\n```\r\ncoupon RACE10 id=7 max_usage=1 trigger=code\r\n== placing unpaid order A ==\r\ncart 94 discount after coupon: 5.000000\r\norder 75 ref=ORD000000000074 discount=5.000000 status=not_paid\r\n== placing unpaid order B (coupon still shows usage left: no decrement happened yet) ==\r\ncart 95 discount after coupon: 5.000000\r\norder 76 ref=ORD000000000075 discount=5.000000 status=not_paid\r\n== merchant marks A paid (cheque cleared) ==\r\ncoupon max_usage now: 0\r\n== merchant marks B paid (second cheque clears) ==\r\ncoupon max_usage now: 0\r\norder 75: status=paid discount=5.000000 order_coupon_rows=1 [coupon=7 amount=5 usageCanceled=]\r\norder 76: status=paid discount=5.000000 order_coupon_rows=1 [coupon=7 amount=5 usageCanceled=]\r\n== merchant cancels B (paid order returned/refunded) ==\r\ncoupon max_usage now: 1 (was 0: B never consumed, yet its cancel minted +1)\r\n== attacker places order C with the minted usage ==\r\ncart 96 discount after coupon: 5.000000\r\norder 77 ref=ORD000000000076 discount=5.000000 status=not_paid\r\n== merchant marks C paid ==\r\ncoupon max_usage now: 0\r\norder 77: status=paid discount=5.000000\r\n```\r\n\r\nStep 6. Control. The same flow ends by attempting one more order after the\r\nminted usage is consumed. No new command is needed. The attempt fails at\r\ncoupon apply time.\r\n\r\nObserved: the run stops in `CouponFactory.php:86` with\r\n`CouponNoUsageLeftException: Maximum usage count reached for coupon RACE10`.\r\nThis confirms the only enforcement point is apply and placement time, and\r\nthe window is exactly stack unpaid orders, then pay. Re-running the whole\r\nflow afterwards fails the same way from the first order.\r\n\r\nCleanup: all proof rows were removed afterwards (orders, carts, coupon,\r\ncustomer) and product stock was restored to the demo baseline.\r\n\r\n### Impact\r\n\r\nAn attacker redeems a usage-limited coupon an unbounded number of times. Each\r\nadditional unpaid order placed before prior ones are counted keeps the full\r\ndiscount once paid, and each paid-then-cancelled phantom order mints a fresh\r\nusage, so the counter inflates past its configured maximum and the loop never\r\nruns dry. Loss scales with the coupon value times the number of live orders.\r\nA `max_usage = 1` coupon yielded three discounted paid orders in the lab, and\r\ncancelling the third would mint again. A 100 percent off single-use code\r\nyields free orders (postage aside). Prerequisites are ordinary shopper\r\ncapabilities: a customer account (or guest checkout where enabled), knowledge\r\nof the code, a pay-later payment module (Cheque ships active by default), and\r\norders reaching paid through merchant confirmation or gateway callbacks.\r\n\r\n\r\n## Suggested Fix\r\n\r\nRe-validate at count time. In `OrderCouponUsageManager::consume()` (or\r\n`CouponManager::decrementQuantity()`), when `getUsagesLeft()` is already zero,\r\ndo not silently keep the benefit: refuse the paid transition or strip the\r\ncoupon from the order (remove the `order_coupon` row or zero its amount and\r\nrecompute totals) and surface an error instead of returning an ignored false.\r\n\r\nOnly release what was actually consumed. Flip `usage_canceled` only when the\r\ndecrement happened, so `release()` skips rows that never consumed. As a\r\nbackstop, clamp `incrementQuantity()` against the coupon's originally\r\nconfigured maximum instead of incrementing blindly (note `max_usage` doubles\r\nas the live counter today, so the original value needs its own record or a\r\nseparate counted column).\r\n\r\nOptional hardening: reserve the usage at placement (decrement in `afterOrder`,\r\nrelease on failure or cancel) inside a row lock or atomic update. That closes\r\nboth the sequential stacking window shown here and the concurrent variant.","categories":["Unclassified"],"product":"thelia/thelia","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-pq5m-7g9w-ppg8","source_modified_at":"2026-10-19T15:18:39Z","fetched_at":"2026-10-23T20:39:26.410340Z","package":{"ecosystem":"composer","name":"thelia/thelia"},"range":">= 3.2.0, < 3.2.4"},{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-pq5m-7g9w-ppg8","source_modified_at":"2026-10-19T15:18:39Z","fetched_at":"2026-10-23T20:39:26.410340Z","package":{"ecosystem":"composer","name":"thelia/thelia"},"range":">= 3.1.0, < 3.1.5"},{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-pq5m-7g9w-ppg8","source_modified_at":"2026-10-19T15:18:39Z","fetched_at":"2026-10-23T20:39:26.410340Z","package":{"ecosystem":"composer","name":"thelia/thelia"},"range":">= 3.0.0, < 3.1.0"},{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-pq5m-7g9w-ppg8","source_modified_at":"2026-10-19T15:18:39Z","fetched_at":"2026-10-23T20:39:26.410340Z","package":{"ecosystem":"composer","name":"thelia/core"},"range":">= 3.2.0, < 3.2.4"},{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-pq5m-7g9w-ppg8","source_modified_at":"2026-10-19T15:18:39Z","fetched_at":"2026-10-23T20:39:26.410340Z","package":{"ecosystem":"composer","name":"thelia/core"},"range":">= 3.1.0, < 3.1.5"},{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-pq5m-7g9w-ppg8","source_modified_at":"2026-10-19T15:18:39Z","fetched_at":"2026-10-23T20:39:26.410340Z","package":{"ecosystem":"composer","name":"thelia/core"},"range":">= 3.0.0, < 3.1.0"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-pq5m-7g9w-ppg8","source_modified_at":"2026-10-19T15:18:39Z","fetched_at":"2026-10-23T20:39:26.410340Z","package":{"ecosystem":"composer","name":"thelia/thelia"},"version":"3.2.4"},{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-pq5m-7g9w-ppg8","source_modified_at":"2026-10-19T15:18:39Z","fetched_at":"2026-10-23T20:39:26.410340Z","package":{"ecosystem":"composer","name":"thelia/thelia"},"version":"3.1.5"},{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-pq5m-7g9w-ppg8","source_modified_at":"2026-10-19T15:18:39Z","fetched_at":"2026-10-23T20:39:26.410340Z","package":{"ecosystem":"composer","name":"thelia/core"},"version":"3.2.4"},{"source":"GitHub Advisory Database","source_url":"https://github.com/thelia/thelia/security/advisories/GHSA-pq5m-7g9w-ppg8","source_modified_at":"2026-10-19T15:18:39Z","fetched_at":"2026-10-23T20:39:26.410340Z","package":{"ecosystem":"composer","name":"thelia/core"},"version":"3.1.5"}],"cvss":4.3,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-18T16:11:40Z","modified_at":"2026-10-19T15:18:39Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/thelia/thelia/security/advisories/GHSA-pq5m-7g9w-ppg8","fetched_at":"2026-10-23T20:39:26.410340Z","source_modified_at":"2026-10-19T15:18:39Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-19T15:18:39Z","content_hash":"53d9066c34257e3de43e3bb429fddb28db18baace1fe82aa6b4f9906bccfcf75"}}
