From 43b1b5724543006655a63db3fec944a2b60a37e9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:18:43 +0000 Subject: [PATCH 1/2] Sign discount coupons with HMAC so they cannot be forged Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Wes Convery <2wconvery@gmail.com> --- .../src/app/payment/payment.component.html | 2 +- frontend/src/app/payment/payment.component.ts | 3 ++- frontend/src/assets/i18n/en.json | 2 +- lib/insecurity.ts | 27 ++++++++++++++----- test/server/insecuritySpec.ts | 11 ++++++-- 5 files changed, 34 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/payment/payment.component.html b/frontend/src/app/payment/payment.component.html index f0dd322f9aa..ceac722e653 100644 --- a/frontend/src/app/payment/payment.component.html +++ b/frontend/src/app/payment/payment.component.html @@ -54,7 +54,7 @@ [innerHtml]="'FOLLOW_FOR_MONTHLY_COUPONS' | translate:{blueSky: blueSkyUrl, reddit: redditUrl}"> - {{coupon.value?.length || 0}}/10 + {{coupon.value?.length || 0}}/{{couponCodeMaxLength}} @if (couponControl.invalid && (couponControl.errors.minlength || couponControl.errors.maxlength)) { {{'COUPON_CODE_HINT' | translate}} diff --git a/frontend/src/app/payment/payment.component.ts b/frontend/src/app/payment/payment.component.ts index 031c74e13a3..6e05c0dcc8e 100644 --- a/frontend/src/app/payment/payment.component.ts +++ b/frontend/src/app/payment/payment.component.ts @@ -71,7 +71,8 @@ export class PaymentComponent implements OnInit { public redditUrl = null public applicationName = 'OWASP Juice Shop' private campaignCoupon: string - public couponControl: UntypedFormControl = new UntypedFormControl('', [Validators.required, Validators.minLength(10), Validators.maxLength(10)]) + public readonly couponCodeMaxLength = 35 + public couponControl: UntypedFormControl = new UntypedFormControl('', [Validators.required, Validators.minLength(10), Validators.maxLength(this.couponCodeMaxLength)]) public clientDate: any public paymentId: any = undefined public couponPanelExpanded = false diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index 3c944416613..ec225c16368 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -352,7 +352,7 @@ "BONUS_POINTS_EARNED": "Bonus Points Earned: {{bonus}}", "BONUS_FOR_FUTURE_PURCHASES": "The bonus points from this order will be added 1:1 to your wallet ยค-fund for future purchases!", "ENTER_COUPON_CODE": "Please enter your coupon code", - "COUPON_CODE_HINT": "Coupon code must be 10 characters long.", + "COUPON_CODE_HINT": "Coupon code must be between 10 and 35 characters long.", "CHARGED_WALLET": "Wallet successfully charged.", "BTN_SHOW_ONLY_TUTORIALS": "Show tutorials only", "INFO_FULL_CHALLENGE_MODE": "Complete the remaining tutorial challenges to unveil all {{num}} challenges and unlock the advanced Score Board filters!", diff --git a/lib/insecurity.ts b/lib/insecurity.ts index b1b7f82c720..ffa9a8a7e1b 100644 --- a/lib/insecurity.ts +++ b/lib/insecurity.ts @@ -96,9 +96,22 @@ export const userEmailFrom = ({ headers }: any) => { return headers ? headers['x-user-email'] : undefined } +const couponSigningKey = process.env.COUPON_SIGNING_KEY ?? privateKey +const MIN_COUPON_SIGNATURE_LENGTH = 16 + +const couponSignature = (payload: string) => { + const digest = crypto.createHmac('sha256', couponSigningKey).update(payload).digest('hex') + // z85 requires the encoded input length to be a multiple of 4 + let length = MIN_COUPON_SIGNATURE_LENGTH + while ((payload.length + 1 + length) % 4 !== 0) { + length++ + } + return digest.substring(0, length) +} + export const generateCoupon = (discount: number, date = new Date()) => { - const coupon = utils.toMMMYY(date) + '-' + discount - return z85.encode(coupon) + const payload = utils.toMMMYY(date) + '-' + discount + return z85.encode(payload + '-' + couponSignature(payload)) } export const discountFromCoupon = (coupon?: string) => { @@ -107,17 +120,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) + 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 diff --git a/test/server/insecuritySpec.ts b/test/server/insecuritySpec.ts index df0a2ca15a6..04c972cb6d1 100644 --- a/test/server/insecuritySpec.ts +++ b/test/server/insecuritySpec.ts @@ -36,8 +36,15 @@ describe('insecurity', () => { describe('generateCoupon', () => { it('returns base85-encoded month, year and discount as coupon code', () => { const coupon = security.generateCoupon(20, new Date('1980-01-02')) - expect(coupon).to.equal('n { + const validity = z85.decode(security.generateCoupon(20)).toString().split('-')[0] + expect(security.discountFromCoupon(z85.encode(validity + '-99-abc'))).to.equal(undefined) + expect(security.discountFromCoupon(z85.encode(validity + '-99-00000000'))).to.equal(undefined) + const tampered = z85.decode(security.generateCoupon(10)).toString().replace('-10-', '-99-') + expect(security.discountFromCoupon(z85.encode(tampered))).to.equal(undefined) }) it('uses current month and year if not specified', () => { From 69579ad51eb1ae74d91fa063759333e56fe73648 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:37:30 +0000 Subject: [PATCH 2/2] Keep signed coupon codes free of characters that break URL decoding and typing Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Wes Convery <2wconvery@gmail.com> --- lib/insecurity.ts | 30 ++++++++++++++++++------------ routes/coupon.ts | 10 +++++++++- test/server/insecuritySpec.ts | 13 +++++++++++++ 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/lib/insecurity.ts b/lib/insecurity.ts index ffa9a8a7e1b..591ed77d9e9 100644 --- a/lib/insecurity.ts +++ b/lib/insecurity.ts @@ -97,21 +97,27 @@ export const userEmailFrom = ({ headers }: any) => { } const couponSigningKey = process.env.COUPON_SIGNING_KEY ?? privateKey -const MIN_COUPON_SIGNATURE_LENGTH = 16 - -const couponSignature = (payload: string) => { - const digest = crypto.createHmac('sha256', couponSigningKey).update(payload).digest('hex') - // z85 requires the encoded input length to be a multiple of 4 - let length = MIN_COUPON_SIGNATURE_LENGTH - while ((payload.length + 1 + length) % 4 !== 0) { - length++ - } - return digest.substring(0, length) +// 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 payload = utils.toMMMYY(date) + '-' + discount - return z85.encode(payload + '-' + couponSignature(payload)) + let coupon = '' + for (const nonce of COUPON_NONCES) { + coupon = z85.encode(payload + '-' + couponSignature(payload, nonce)) + if (!UNSAFE_COUPON_CHARS.test(coupon)) { + break + } + } + return coupon } export const discountFromCoupon = (coupon?: string) => { @@ -121,7 +127,7 @@ export const discountFromCoupon = (coupon?: string) => { const decoded = z85.decode(coupon) if (decoded && (hasValidFormat(decoded.toString()) != null)) { const [validity, discount, signature] = decoded.toString().split('-') - const expected = couponSignature(validity + '-' + discount) + const expected = couponSignature(validity + '-' + discount, signature.charAt(0)) if (signature.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { return undefined } diff --git a/routes/coupon.ts b/routes/coupon.ts index 729688bf73e..dc8de9469ba 100644 --- a/routes/coupon.ts +++ b/routes/coupon.ts @@ -11,7 +11,7 @@ export function applyCoupon () { return async ({ params }: Request, res: Response, next: NextFunction) => { try { const id = params.id - let coupon: string | undefined | null = params.coupon ? decodeURIComponent(params.coupon) : undefined + let coupon: string | undefined | null = params.coupon ? safeDecodeURIComponent(params.coupon) : undefined const discount = security.discountFromCoupon(coupon) coupon = discount ? coupon : null @@ -32,3 +32,11 @@ export function applyCoupon () { } } } + +function safeDecodeURIComponent (value: string) { + try { + return decodeURIComponent(value) + } catch { + return value + } +} diff --git a/test/server/insecuritySpec.ts b/test/server/insecuritySpec.ts index 04c972cb6d1..1021501b276 100644 --- a/test/server/insecuritySpec.ts +++ b/test/server/insecuritySpec.ts @@ -45,6 +45,19 @@ describe('insecurity', () => { expect(security.discountFromCoupon(z85.encode(validity + '-99-00000000'))).to.equal(undefined) const tampered = z85.decode(security.generateCoupon(10)).toString().replace('-10-', '-99-') expect(security.discountFromCoupon(z85.encode(tampered))).to.equal(undefined) + const [validity2, discount, signature] = z85.decode(security.generateCoupon(10)).toString().split('-') + const swappedNonce = (parseInt(signature.charAt(0), 16) + 1) % 16 + expect(security.discountFromCoupon(z85.encode(validity2 + '-' + discount + '-' + swappedNonce.toString(16) + signature.substring(1)))).to.equal(undefined) + }) + + it('generates coupon codes without characters that break URL decoding or automated input', () => { + for (let month = 0; month < 12; month++) { + for (const discount of [10, 15, 20, 50, 80, 90, 99]) { + const coupon = security.generateCoupon(discount, new Date(2024, month, 1)) + expect(coupon.length).to.equal(35) + expect(coupon).to.not.match(/\{[^{}]*\}/) + } + } }) it('uses current month and year if not specified', () => {