From 81118245ce6f6b6667d9cb41eb42e78a4cb0019e Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Fri, 14 Aug 2026 08:36:16 -0500 Subject: [PATCH] fix: sonarqube denial of service issue --- backend/src/utils.ts | 29 +++++++++++++++++++++++++++-- backend/tests/unit/utils.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/backend/src/utils.ts b/backend/src/utils.ts index 1a06671..29aa561 100755 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -10,9 +10,34 @@ export function formatPrice(cents: number): string { return `$${(cents / 100).toFixed(2)}`; } -const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +// RFC 5321 caps an address at 254 characters; reject anything longer up front so +// validation cost stays bounded regardless of what a client posts. +const MAX_EMAIL_LENGTH = 254; + +// Both patterns are anchored single character classes with no overlapping +// alternatives, so they match in linear time. Splitting on '@' and '.' in code +// rather than in one combined pattern avoids the ambiguous (and backtracking) +// `[^\s@]+\.[^\s@]+` domain match. +const LOCAL_PART_RE = /^[^\s@]+$/; +const DOMAIN_LABEL_RE = /^[^\s@.]+$/; + export function isValidEmail(email: string): boolean { - return EMAIL_RE.test(email.trim()); + const trimmed = email.trim(); + if (trimmed.length === 0 || trimmed.length > MAX_EMAIL_LENGTH) { + return false; + } + + const at = trimmed.indexOf('@'); + if (at === -1 || at !== trimmed.lastIndexOf('@')) { + return false; + } + + if (!LOCAL_PART_RE.test(trimmed.slice(0, at))) { + return false; + } + + const labels = trimmed.slice(at + 1).split('.'); + return labels.length >= 2 && labels.every((label) => DOMAIN_LABEL_RE.test(label)); } export const MARKETING_CONSENT_TEXT = diff --git a/backend/tests/unit/utils.test.ts b/backend/tests/unit/utils.test.ts index 7aba59d..085f714 100755 --- a/backend/tests/unit/utils.test.ts +++ b/backend/tests/unit/utils.test.ts @@ -44,4 +44,31 @@ describe('isValidEmail', () => { it('rejects an email with no domain', () => { expect(isValidEmail('thom@')).toBe(false); }); + + it('accepts a multi-label domain', () => { + expect(isValidEmail('thom@mail.example.co.uk')).toBe(true); + }); + + it('rejects a domain with no dot', () => { + expect(isValidEmail('thom@localhost')).toBe(false); + }); + + it('rejects an empty domain label', () => { + expect(isValidEmail('thom@example..com')).toBe(false); + expect(isValidEmail('thom@.com')).toBe(false); + }); + + it('rejects more than one @', () => { + expect(isValidEmail('thom@foo@example.com')).toBe(false); + }); + + it('rejects an address longer than 254 characters', () => { + expect(isValidEmail(`${'a'.repeat(250)}@example.com`)).toBe(false); + }); + + it('rejects a long dotless domain without super-linear backtracking', () => { + const start = Date.now(); + expect(isValidEmail(`thom@${'a'.repeat(60_000)}`)).toBe(false); + expect(Date.now() - start).toBeLessThan(1000); + }); });