-
Notifications
You must be signed in to change notification settings - Fork 2
Fix: Forgeable discount coupons: unsigned z85-encoded coupon codes accepted by PUT /rest/basket/:id/coupon/:coupon allow arbitrary discounts up to 99% #361
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -96,9 +96,28 @@ export const userEmailFrom = ({ headers }: any) => { | |
| return headers ? headers['x-user-email'] : undefined | ||
| } | ||
|
|
||
| const couponSigningKey = process.env.COUPON_SIGNING_KEY ?? privateKey | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed that the fallback key is public, so a deployment that sets neither |
||
| // signature = one hex nonce digit + truncated HMAC-SHA256(payload + nonce) | ||
| const COUPON_SIGNATURE_LENGTH = 19 // (payload.length + 1 + 19) % 4 === 0, as required by z85 | ||
| const COUPON_NONCES = '0123456789abcdef' | ||
| // z85 output may contain '%' (breaks URL path decoding) or '{...}' (interpreted as key sequence by automated typing) | ||
| const UNSAFE_COUPON_CHARS = /%|\{[^{}]*\}/ | ||
|
|
||
| const couponSignature = (payload: string, nonce: string) => { | ||
| const digest = crypto.createHmac('sha256', couponSigningKey).update(payload + nonce).digest('hex') | ||
| return nonce + digest.substring(0, COUPON_SIGNATURE_LENGTH - 1) | ||
| } | ||
|
|
||
| export const generateCoupon = (discount: number, date = new Date()) => { | ||
| const coupon = utils.toMMMYY(date) + '-' + discount | ||
| return z85.encode(coupon) | ||
| const payload = utils.toMMMYY(date) + '-' + discount | ||
|
Comment on lines
111
to
+112
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Out-of-range values are rejected at verification time rather than generation time: |
||
| let coupon = '' | ||
| for (const nonce of COUPON_NONCES) { | ||
| coupon = z85.encode(payload + '-' + couponSignature(payload, nonce)) | ||
| if (!UNSAFE_COUPON_CHARS.test(coupon)) { | ||
| break | ||
| } | ||
| } | ||
| return coupon | ||
|
Comment on lines
+114
to
+120
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct — when the |
||
| } | ||
|
|
||
| export const discountFromCoupon = (coupon?: string) => { | ||
|
|
@@ -107,17 +126,19 @@ export const discountFromCoupon = (coupon?: string) => { | |
| } | ||
| const decoded = z85.decode(coupon) | ||
| if (decoded && (hasValidFormat(decoded.toString()) != null)) { | ||
| const parts = decoded.toString().split('-') | ||
| const validity = parts[0] | ||
| const [validity, discount, signature] = decoded.toString().split('-') | ||
| const expected = couponSignature(validity + '-' + discount, signature.charAt(0)) | ||
| if (signature.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { | ||
| return undefined | ||
| } | ||
| if (utils.toMMMYY(new Date()) === validity) { | ||
| const discount = parts[1] | ||
| return parseInt(discount) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function hasValidFormat (coupon: string) { | ||
| return coupon.match(/(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)[0-9]{2}-[0-9]{2}/) | ||
| return coupon.match(/^(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)[0-9]{2}-[0-9]{2}-[0-9a-f]+$/) | ||
| } | ||
|
|
||
| // vuln-code-snippet start redirectCryptoCurrencyChallenge redirectChallenge | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.