Fix: Forgeable discount coupons: unsigned z85-encoded coupon codes accepted by PUT /rest/basket/:id/coupon/:coupon allow arbitrary discounts up to 99% - #361
Conversation
Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Wes Convery <2wconvery@gmail.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
…nd typing Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Wes Convery <2wconvery@gmail.com>
| for (const nonce of COUPON_NONCES) { | ||
| coupon = z85.encode(payload + '-' + couponSignature(payload, nonce)) | ||
| if (!UNSAFE_COUPON_CHARS.test(coupon)) { | ||
| break | ||
| } | ||
| } | ||
| return coupon |
There was a problem hiding this comment.
Correct — when the % sits in the z85 blocks that encode the fixed <MMMYY>-<dd> prefix, no nonce can remove it (≈4.7% of month/discount combos; this was equally true of the old unsigned 10-char codes). That case is covered by the second half of this commit: routes/coupon.ts now falls back to the raw param when decodeURIComponent throws, so such a code redeems normally instead of returning HTTP 500. The nonce loop is best-effort for the % case and effectively complete for the {…} case (the closing } needs to land in the signature blocks, which the nonce does vary).
| return headers ? headers['x-user-email'] : undefined | ||
| } | ||
|
|
||
| const couponSigningKey = process.env.COUPON_SIGNING_KEY ?? privateKey |
There was a problem hiding this comment.
There was a problem hiding this comment.
Agreed that the fallback key is public, so a deployment that sets neither COUPON_SIGNING_KEY nor a private JWT key does not get authenticity from this change. This is deliberate scoping: the hard-coded JWT/HMAC key in lib/insecurity.ts is a separate, already-tracked finding for this repo, and it is the same key that already protects every session token — a deployment that has rotated it (as it must for JWTs) gets unforgeable coupons for free. A per-process random default was rejected because cypress.config.ts generates coupons out-of-process and multi-instance deployments would disagree on signatures. Operators wanting an independent secret set COUPON_SIGNING_KEY. Happy to switch to a random default if maintainers prefer that trade-off.
| export const generateCoupon = (discount: number, date = new Date()) => { | ||
| const coupon = utils.toMMMYY(date) + '-' + discount | ||
| return z85.encode(coupon) | ||
| const payload = utils.toMMMYY(date) + '-' + discount |
There was a problem hiding this comment.
Out-of-range values are rejected at verification time rather than generation time: hasValidFormat only matches -[0-9]{2}-, so a coupon signed over 100, 5, -5 or 12.5 fails the format check in discountFromCoupon and returns undefined (404 at the route). Discounts >99% or negative totals via a coupon are therefore not reachable; this is unchanged from the base branch. The chatbot's generateCoupon tool is pre-existing and its input handling is out of scope here.
Description
Finding: Forgeable discount coupons: unsigned z85-encoded coupon codes accepted by PUT /rest/basket/:id/coupon/:coupon allow arbitrary discounts up to 99%
Repo: COG-GTM/juice-shop
Severity: HIGH
Fix approach: Coupon codes now carry a truncated HMAC-SHA256 signature (72 bits) over the
<MMMYY>-<discount>payload (lib/insecurity.ts), anddiscountFromCouponrejects any code whose signature does not verify (constant-time compare), so an attacker can no longer mint a discount by z85-encoding a plaintext payload.Supersedes #360 (closed by the contribution bot: wrong base branch / missing DCO sign-off); this PR targets
develop, is signed off, and addresses the Devin Review findings from #360.%(breaks the route'sdecodeURIComponent→ HTTP 500) and{…}(Cypresstype()treats it as a key sequence). The unsigned 10-char codes had the same latent problem, but the longer signed codes hit it deterministically this month (SEP26-99→…{HeO…%vR…), which is what failedapi-testand bothe2e-testjobs on the first revision. The generator now tries 16 nonces and picks the first code free of those sequences;routes/coupon.tsadditionally falls back to the raw param ifdecodeURIComponentthrows, so a%inside a code can no longer 500.COUPON_SIGNING_KEYenv var, falling back to the app's existing JWT private key socypress.config.ts(which generates coupons out-of-process) stays consistent with the server.payment.componentcoupon validator changed from exactly 10 to 10–35 so both signed codes and the 10-char client-side campaign codes (WMNSDY2019, …) remain enterable.COUPON_CODE_HINT(en) updated accordingly.insecuritySpec.ts: the test pinning the old unsigned literal (n<MiifFb4l) is updated to assert on the payload prefix; forgery/tamper/nonce-swap tests and a URL/typing-safety test are added.Verified:
mocha -r tsx test/server/insecuritySpec.ts(37 passing), backendeslint+tsc --noEmit, frontendeslinton changed files (only pre-existinglabel-has-associated-controlerrors remain, present on base).Resolved or fixed issue: none
AI Tool Disclosure
DevinDevin (Cognition)Automated security sweep: fix forgeable unsigned coupon codes by signing themAffirmation
Link to Devin session: https://app.devin.ai/sessions/b243e9dbc6d0422fb8bc01103a1ae3ae
Open in Devin Desktop: https://app.devin.ai/desktop/session/b243e9dbc6d0422fb8bc01103a1ae3ae?variant=devin
Requested by: @WesternConcrete