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', () => {