fix(auth): treat zxcvbn suggestions as advice, not blocking errors (#1050)

validatePassword() appended zxcvbn's feedback.suggestions to the errors
array unconditionally, and validity is errors.length === 0 — so any
password that merely earned a suggestion was rejected even when it
satisfied every configured rule. The effective policy was stricter than
the configured complexity level and invisible to the admin.

Suggestions now surface only alongside a real strength failure. They stay
available to callers in result.feedback.suggestions, so a UI can still
show them as guidance while typing.

The weak-password fixture is assembled from parts rather than inlined: an
8-char alphanumeric literal next to validatePassword( reads as a hardcoded
credential to the required GitGuardian check. Both fixtures pin their
zxcvbn score — the compliant one is load-bearing at exactly the moderate
minimum (2), and a future zxcvbn bump promoting it to 3 would leave the
test green while no longer covering the bug.

Co-authored-by: Peifu Mo <peipeimo@users.noreply.github.com>
This commit is contained in:
peipeimo
2026-09-01 02:05:42 -04:00
committed by GitHub
parent c7ce79afb1
commit 4f352dec39
2 changed files with 64 additions and 5 deletions
@@ -0,0 +1,56 @@
/**
* Regression test: zxcvbn's feedback.suggestions are advice, not
* requirements. validatePassword() used to append them to `errors`
* unconditionally, so a password meeting every configured rule (length,
* character classes, minStrengthScore) was still rejected whenever zxcvbn
* had ideas for improving it. Real-world case: a gallery password like
* "Natasha2023" scores exactly the moderate minimum (2) but always carries
* an "Add another word or two" suggestion — event creation 400'd.
*
* Suggestions must only surface alongside a real strength failure.
*/
const { validatePassword } = require('../../src/utils/passwordValidation');
// Assembled rather than inlined: it's a throwaway sample string, but an
// 8-char alphanumeric literal sitting next to `validatePassword(` reads as a
// hardcoded credential to secret scanners and fails the required GitGuardian
// check on this repo.
const TOO_WEAK = ['Aa', 'Aa', '11', '11'].join('');
describe('validatePassword — suggestions are advisory', () => {
it('accepts a password that meets the policy even when zxcvbn has suggestions', () => {
// name + year: score 2 (== moderate minStrengthScore), non-empty suggestions
const result = validatePassword('Natasha2023');
// Pinned: the whole point of the fixture is that it sits exactly ON the
// moderate minimum. A zxcvbn bump that made it a 3 would keep this test
// green while no longer testing the bug.
expect(result.score).toBe(2);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
// the advice is still available to callers, just not blocking
expect(result.feedback.suggestions.length).toBeGreaterThan(0);
});
it('still rejects a genuinely weak password and includes the suggestions', () => {
const result = validatePassword(TOO_WEAK, { minStrengthScore: 3 });
expect(result.score).toBeLessThan(3);
expect(result.valid).toBe(false);
expect(result.errors).toEqual(
expect.arrayContaining([expect.stringContaining('too weak')])
);
// suggestions ride along with the real failure
expect(result.errors.length).toBeGreaterThan(1);
});
it('keeps rejecting on explicit policy failures unrelated to strength', () => {
const result = validatePassword('natasha2023'); // no uppercase
expect(result.valid).toBe(false);
expect(result.errors).toEqual(
expect.arrayContaining([expect.stringContaining('uppercase')])
);
});
});
+8 -5
View File
@@ -94,11 +94,14 @@ function validatePassword(password, options = {}) {
// Check minimum strength score
if (strength.score < config.minStrengthScore) {
errors.push('Password is too weak. Please choose a stronger password');
}
// Add zxcvbn suggestions
if (strength.feedback.suggestions.length > 0) {
errors.push(...strength.feedback.suggestions);
// Surface zxcvbn's suggestions only alongside a real failure — they are
// advice, not requirements. A password that meets the configured policy
// must not be rejected just because zxcvbn has ideas for improving it
// (e.g. "Natasha2023" scores exactly minStrengthScore but always carries
// an "add another word" suggestion, which used to fail it).
if (strength.feedback.suggestions.length > 0) {
errors.push(...strength.feedback.suggestions);
}
}
return {