From 395e30ab44f3879ce11435eb7985d26eee95bd39 Mon Sep 17 00:00:00 2001 From: Krishna Date: Wed, 12 Aug 2026 11:32:17 +0530 Subject: [PATCH 1/5] fix(password-policies): sanitize and validate forbidRepeatingCharactersCount before constructing RegExp --- .../src/PasswordPolicy.spec.ts | 27 +++++++++++++++++++ .../password-policies/src/PasswordPolicy.ts | 18 ++++++++----- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/password-policies/src/PasswordPolicy.spec.ts b/packages/password-policies/src/PasswordPolicy.spec.ts index c85cfc64586e2..891f6ccdc83e7 100644 --- a/packages/password-policies/src/PasswordPolicy.spec.ts +++ b/packages/password-policies/src/PasswordPolicy.spec.ts @@ -216,4 +216,31 @@ describe('Password Policy', () => { // since its default value is 3 expect(policy.policy.length).toBe(1); }); + + it.each([-1, 0, 1.5, Number.NaN, 1e21, '3'])( + 'should use the default repeating character count when configured with %p', + (count) => { + const passwordPolicy = new PasswordPolicy({ + enabled: true, + forbidRepeatingCharacters: true, + forbidRepeatingCharactersCount: count as number, + throwError: false, + }); + + // Default count is 3 → "111" is allowed, "1111" is not + expect(passwordPolicy.validate('111')).toBe(true); + expect(passwordPolicy.validate('1111')).toBe(false); + + expect(passwordPolicy.sendValidationMessage('1111')).toContainEqual({ + name: 'get-password-policy-forbidRepeatingCharactersCount', + isValid: false, + limit: 3, + }); + + expect(passwordPolicy.getPasswordPolicy().policy).toContainEqual([ + 'get-password-policy-forbidRepeatingCharactersCount', + { forbidRepeatingCharactersCount: 3 }, + ]); + }, +); }); diff --git a/packages/password-policies/src/PasswordPolicy.ts b/packages/password-policies/src/PasswordPolicy.ts index d46c3740ebd34..0ba90064b6737 100644 --- a/packages/password-policies/src/PasswordPolicy.ts +++ b/packages/password-policies/src/PasswordPolicy.ts @@ -16,8 +16,8 @@ type PasswordPolicyName = `get-password-policy-${K} type PasswordPolicyParametersEntry = { [K in PasswordPolicyKey]: PasswordPolicyMap[K] extends number - ? [PasswordPolicyName, Record] - : [PasswordPolicyName]; + ? [PasswordPolicyName, Record] + : [PasswordPolicyName]; }[PasswordPolicyKey]; type PasswordPolicyType = { @@ -34,8 +34,8 @@ export type PasswordPolicyOptions = Partial< export type PasswordPolicyValidation = { [K in PasswordPolicyKey]: PasswordPolicyMap[K] extends number - ? { name: PasswordPolicyName; limit: number } - : { name: PasswordPolicyName }; + ? { name: PasswordPolicyName; limit: number } + : { name: PasswordPolicyName }; }[PasswordPolicyKey] & { isValid: boolean }; export class PasswordPolicy { @@ -79,11 +79,17 @@ export class PasswordPolicy { mustContainAtLeastOneSpecialCharacter = false, throwError = true, }: PasswordPolicyOptions) { + const safeForbidRepeatingCharactersCount = + typeof forbidRepeatingCharactersCount === 'number' && + Number.isSafeInteger(forbidRepeatingCharactersCount) && + forbidRepeatingCharactersCount >= 1 ? forbidRepeatingCharactersCount : 3; + + this.enabled = enabled; this.minLength = minLength; this.maxLength = maxLength; this.forbidRepeatingCharacters = forbidRepeatingCharacters; - this.forbidRepeatingCharactersCount = forbidRepeatingCharactersCount; + this.forbidRepeatingCharactersCount = safeForbidRepeatingCharactersCount; this.mustContainAtLeastOneLowercase = mustContainAtLeastOneLowercase; this.mustContainAtLeastOneUppercase = mustContainAtLeastOneUppercase; this.mustContainAtLeastOneNumber = mustContainAtLeastOneNumber; @@ -91,7 +97,7 @@ export class PasswordPolicy { this.throwError = throwError; this.regex = { - forbiddingRepeatingCharacters: new RegExp(`(.)\\1{${forbidRepeatingCharactersCount},}`), + forbiddingRepeatingCharacters: new RegExp(`(.)\\1{${safeForbidRepeatingCharactersCount},}`), mustContainAtLeastOneLowercase: new RegExp('[a-z]'), mustContainAtLeastOneUppercase: new RegExp('[A-Z]'), mustContainAtLeastOneNumber: new RegExp('[0-9]'), From 6d90e6d54e72e5ac2046827736f49f71831eebf7 Mon Sep 17 00:00:00 2001 From: Krishna Date: Mon, 17 Aug 2026 23:55:59 +0530 Subject: [PATCH 2/5] fix(password-policies): update validation for forbidRepeatingCharactersCount to allow zero --- packages/password-policies/src/PasswordPolicy.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/password-policies/src/PasswordPolicy.ts b/packages/password-policies/src/PasswordPolicy.ts index 0ba90064b6737..57d0afb0e448c 100644 --- a/packages/password-policies/src/PasswordPolicy.ts +++ b/packages/password-policies/src/PasswordPolicy.ts @@ -82,8 +82,7 @@ export class PasswordPolicy { const safeForbidRepeatingCharactersCount = typeof forbidRepeatingCharactersCount === 'number' && Number.isSafeInteger(forbidRepeatingCharactersCount) && - forbidRepeatingCharactersCount >= 1 ? forbidRepeatingCharactersCount : 3; - + forbidRepeatingCharactersCount >= 0 ? forbidRepeatingCharactersCount : 3; this.enabled = enabled; this.minLength = minLength; @@ -268,8 +267,6 @@ export class PasswordPolicy { } if (this.forbidRepeatingCharacters) { data.policy.push(['get-password-policy-forbidRepeatingCharacters']); - } - if (this.forbidRepeatingCharactersCount) { data.policy.push([ 'get-password-policy-forbidRepeatingCharactersCount', { forbidRepeatingCharactersCount: this.forbidRepeatingCharactersCount }, From 96ae16fdd31f483e65f5449bbb3ffd3866b3f578 Mon Sep 17 00:00:00 2001 From: Krishna Date: Mon, 17 Aug 2026 23:56:13 +0530 Subject: [PATCH 3/5] fix(password-policies): prevent crash on invalid forbidRepeatingCharactersCount and preserve zero as a valid setting --- .../fix-password-policy-regex-validation.md | 5 ++ .../src/PasswordPolicy.spec.ts | 60 ++++++++++++------- 2 files changed, 45 insertions(+), 20 deletions(-) create mode 100644 .changeset/fix-password-policy-regex-validation.md diff --git a/.changeset/fix-password-policy-regex-validation.md b/.changeset/fix-password-policy-regex-validation.md new file mode 100644 index 0000000000000..b4592d5704736 --- /dev/null +++ b/.changeset/fix-password-policy-regex-validation.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/password-policies': patch +--- + +Fixed a crash where an invalid `forbidRepeatingCharactersCount` password policy setting (negative, non-integer, `NaN`, unsafely large, or non-numeric) could throw a `SyntaxError` when building the repeating-characters `RegExp`. Invalid values now fall back to the default of `3`. A configured value of `0` is preserved as a valid, distinct setting instead of being treated as "unset". diff --git a/packages/password-policies/src/PasswordPolicy.spec.ts b/packages/password-policies/src/PasswordPolicy.spec.ts index 891f6ccdc83e7..4b0542fd2af6c 100644 --- a/packages/password-policies/src/PasswordPolicy.spec.ts +++ b/packages/password-policies/src/PasswordPolicy.spec.ts @@ -212,35 +212,55 @@ describe('Password Policy', () => { const policy = passwordPolicy.getPasswordPolicy(); expect(policy.enabled).toBe(true); - // even when no policy is specified, forbidRepeatingCharactersCount is still configured - // since its default value is 3 - expect(policy.policy.length).toBe(1); + // forbidRepeatingCharacters is disabled by default, so the count entry + // (and its default of 3) is not reported as an active policy + expect(policy.policy.length).toBe(0); }); - - it.each([-1, 0, 1.5, Number.NaN, 1e21, '3'])( - 'should use the default repeating character count when configured with %p', - (count) => { + + it.each([-1, 1.5, Number.NaN, 1e21, '3'])( + 'should use the default repeating character count when configured with %p', + (count) => { + const passwordPolicy = new PasswordPolicy({ + enabled: true, + forbidRepeatingCharacters: true, + forbidRepeatingCharactersCount: count as number, + throwError: false, + }); + + // Default count is 3 → "111" is allowed, "1111" is not + expect(passwordPolicy.validate('111')).toBe(true); + expect(passwordPolicy.validate('1111')).toBe(false); + + expect(passwordPolicy.sendValidationMessage('1111')).toContainEqual({ + name: 'get-password-policy-forbidRepeatingCharactersCount', + isValid: false, + limit: 3, + }); + + expect(passwordPolicy.getPasswordPolicy().policy).toContainEqual([ + 'get-password-policy-forbidRepeatingCharactersCount', + { forbidRepeatingCharactersCount: 3 }, + ]); + }, + ); + + it('should preserve a configured count of 0 instead of falling back to the default', () => { const passwordPolicy = new PasswordPolicy({ enabled: true, forbidRepeatingCharacters: true, - forbidRepeatingCharactersCount: count as number, + forbidRepeatingCharactersCount: 0, throwError: false, }); - // Default count is 3 → "111" is allowed, "1111" is not - expect(passwordPolicy.validate('111')).toBe(true); - expect(passwordPolicy.validate('1111')).toBe(false); + expect(passwordPolicy.getPasswordPolicy().policy).toContainEqual([ + 'get-password-policy-forbidRepeatingCharactersCount', + { forbidRepeatingCharactersCount: 0 }, + ]); - expect(passwordPolicy.sendValidationMessage('1111')).toContainEqual({ + expect(passwordPolicy.sendValidationMessage('1')).toContainEqual({ name: 'get-password-policy-forbidRepeatingCharactersCount', isValid: false, - limit: 3, + limit: 0, }); - - expect(passwordPolicy.getPasswordPolicy().policy).toContainEqual([ - 'get-password-policy-forbidRepeatingCharactersCount', - { forbidRepeatingCharactersCount: 3 }, - ]); - }, -); + }); }); From dbfac75a88e2cb0a11d35cb68db1b95af95f1e05 Mon Sep 17 00:00:00 2001 From: Krishna Date: Sun, 23 Aug 2026 11:35:59 +0530 Subject: [PATCH 4/5] fix(password-policies): reject a repeating-character count of 0 and drop formatting churn --- .../fix-password-policy-regex-validation.md | 2 +- .../src/PasswordPolicy.spec.ts | 70 ++++++++++--------- .../password-policies/src/PasswordPolicy.ts | 16 +++-- 3 files changed, 46 insertions(+), 42 deletions(-) diff --git a/.changeset/fix-password-policy-regex-validation.md b/.changeset/fix-password-policy-regex-validation.md index b4592d5704736..f037afa5090a6 100644 --- a/.changeset/fix-password-policy-regex-validation.md +++ b/.changeset/fix-password-policy-regex-validation.md @@ -2,4 +2,4 @@ '@rocket.chat/password-policies': patch --- -Fixed a crash where an invalid `forbidRepeatingCharactersCount` password policy setting (negative, non-integer, `NaN`, unsafely large, or non-numeric) could throw a `SyntaxError` when building the repeating-characters `RegExp`. Invalid values now fall back to the default of `3`. A configured value of `0` is preserved as a valid, distinct setting instead of being treated as "unset". +Fixed the `forbidRepeatingCharactersCount` password policy silently turning itself off when configured with an invalid value. A negative, fractional, `NaN`, unsafely large or non-numeric count produced an invalid quantifier (`{-1,}`, `{1.5,}`, `{NaN,}`, `{1e+21,}`), which the regex engine reads as a literal, so passwords made entirely of repeated characters were accepted. A count of `0` had the opposite effect, rejecting every non-empty password and locking users out of setting one. Both cases now fall back to the default of `3`. diff --git a/packages/password-policies/src/PasswordPolicy.spec.ts b/packages/password-policies/src/PasswordPolicy.spec.ts index 4b0542fd2af6c..c8dda25093d6b 100644 --- a/packages/password-policies/src/PasswordPolicy.spec.ts +++ b/packages/password-policies/src/PasswordPolicy.spec.ts @@ -217,50 +217,52 @@ describe('Password Policy', () => { expect(policy.policy.length).toBe(0); }); - it.each([-1, 1.5, Number.NaN, 1e21, '3'])( - 'should use the default repeating character count when configured with %p', - (count) => { - const passwordPolicy = new PasswordPolicy({ - enabled: true, - forbidRepeatingCharacters: true, - forbidRepeatingCharactersCount: count as number, - throwError: false, - }); - - // Default count is 3 → "111" is allowed, "1111" is not - expect(passwordPolicy.validate('111')).toBe(true); - expect(passwordPolicy.validate('1111')).toBe(false); - - expect(passwordPolicy.sendValidationMessage('1111')).toContainEqual({ - name: 'get-password-policy-forbidRepeatingCharactersCount', - isValid: false, - limit: 3, - }); - - expect(passwordPolicy.getPasswordPolicy().policy).toContainEqual([ - 'get-password-policy-forbidRepeatingCharactersCount', - { forbidRepeatingCharactersCount: 3 }, - ]); - }, - ); - - it('should preserve a configured count of 0 instead of falling back to the default', () => { + it.each([0, -1, 1.5, Number.NaN, 1e21, '3'])('should use the default repeating character count when configured with %p', (count) => { const passwordPolicy = new PasswordPolicy({ enabled: true, forbidRepeatingCharacters: true, - forbidRepeatingCharactersCount: 0, + forbidRepeatingCharactersCount: count as number, throwError: false, }); + // Default count is 3 → "111" is allowed, "1111" is not + expect(passwordPolicy.validate('111')).toBe(true); + expect(passwordPolicy.validate('1111')).toBe(false); + + expect(passwordPolicy.sendValidationMessage('1111')).toContainEqual({ + name: 'get-password-policy-forbidRepeatingCharactersCount', + isValid: false, + limit: 3, + }); + expect(passwordPolicy.getPasswordPolicy().policy).toContainEqual([ 'get-password-policy-forbidRepeatingCharactersCount', - { forbidRepeatingCharactersCount: 0 }, + { forbidRepeatingCharactersCount: 3 }, ]); + }); - expect(passwordPolicy.sendValidationMessage('1')).toContainEqual({ - name: 'get-password-policy-forbidRepeatingCharactersCount', - isValid: false, - limit: 0, + it('should not lock every password out when configured with a count of 0', () => { + const passwordPolicy = new PasswordPolicy({ + enabled: true, + forbidRepeatingCharacters: true, + forbidRepeatingCharactersCount: 0, + throwError: false, }); + + // `(.)\1{0,}` matches any non-empty string, so a count of 0 would reject every password + expect(passwordPolicy.validate('1')).toBe(true); + expect(passwordPolicy.validate('Passw0rd!')).toBe(true); + }); + + it('should keep enforcing the rule when configured with an unusable count', () => { + const passwordPolicy = new PasswordPolicy({ + enabled: true, + forbidRepeatingCharacters: true, + forbidRepeatingCharactersCount: -1, + throwError: false, + }); + + // a `{-1,}` quantifier is read as a literal, which would silently disable the rule + expect(passwordPolicy.validate('11111111')).toBe(false); }); }); diff --git a/packages/password-policies/src/PasswordPolicy.ts b/packages/password-policies/src/PasswordPolicy.ts index 57d0afb0e448c..b51c61a38eede 100644 --- a/packages/password-policies/src/PasswordPolicy.ts +++ b/packages/password-policies/src/PasswordPolicy.ts @@ -16,8 +16,8 @@ type PasswordPolicyName = `get-password-policy-${K} type PasswordPolicyParametersEntry = { [K in PasswordPolicyKey]: PasswordPolicyMap[K] extends number - ? [PasswordPolicyName, Record] - : [PasswordPolicyName]; + ? [PasswordPolicyName, Record] + : [PasswordPolicyName]; }[PasswordPolicyKey]; type PasswordPolicyType = { @@ -34,8 +34,8 @@ export type PasswordPolicyOptions = Partial< export type PasswordPolicyValidation = { [K in PasswordPolicyKey]: PasswordPolicyMap[K] extends number - ? { name: PasswordPolicyName; limit: number } - : { name: PasswordPolicyName }; + ? { name: PasswordPolicyName; limit: number } + : { name: PasswordPolicyName }; }[PasswordPolicyKey] & { isValid: boolean }; export class PasswordPolicy { @@ -79,10 +79,12 @@ export class PasswordPolicy { mustContainAtLeastOneSpecialCharacter = false, throwError = true, }: PasswordPolicyOptions) { + // Anything that is not a plain positive integer is interpolated into the quantifier below as + // `{NaN,}`, `{-1,}`, `{1.5,}` or `{1e+21,}`, none of which are valid quantifiers: the engine reads + // them as literals instead of throwing, and the rule silently stops matching repeated characters. + // A count of 0 is rejected for the opposite reason — `(.)\1{0,}` matches every non-empty password. const safeForbidRepeatingCharactersCount = - typeof forbidRepeatingCharactersCount === 'number' && - Number.isSafeInteger(forbidRepeatingCharactersCount) && - forbidRepeatingCharactersCount >= 0 ? forbidRepeatingCharactersCount : 3; + Number.isSafeInteger(forbidRepeatingCharactersCount) && forbidRepeatingCharactersCount >= 1 ? forbidRepeatingCharactersCount : 3; this.enabled = enabled; this.minLength = minLength; From cbe7375d733ff7998ef47f3dca0288b63f6c4ee7 Mon Sep 17 00:00:00 2001 From: Krishna Date: Tue, 25 Aug 2026 21:04:34 +0530 Subject: [PATCH 5/5] chore(password-policies): condense the repeating-count sanitization comment --- packages/password-policies/src/PasswordPolicy.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/password-policies/src/PasswordPolicy.ts b/packages/password-policies/src/PasswordPolicy.ts index b51c61a38eede..e01d7edea7f3f 100644 --- a/packages/password-policies/src/PasswordPolicy.ts +++ b/packages/password-policies/src/PasswordPolicy.ts @@ -79,10 +79,8 @@ export class PasswordPolicy { mustContainAtLeastOneSpecialCharacter = false, throwError = true, }: PasswordPolicyOptions) { - // Anything that is not a plain positive integer is interpolated into the quantifier below as - // `{NaN,}`, `{-1,}`, `{1.5,}` or `{1e+21,}`, none of which are valid quantifiers: the engine reads - // them as literals instead of throwing, and the rule silently stops matching repeated characters. - // A count of 0 is rejected for the opposite reason — `(.)\1{0,}` matches every non-empty password. + // Invalid counts interpolate as literals rather than throwing, so the rule + // would silently stop matching; `{0,}` matches every non-empty password. const safeForbidRepeatingCharactersCount = Number.isSafeInteger(forbidRepeatingCharactersCount) && forbidRepeatingCharactersCount >= 1 ? forbidRepeatingCharactersCount : 3;