From aa8ebf12bd2e492610761514241ffbdb7b3fac5f Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sat, 30 May 2026 21:27:00 +0100 Subject: [PATCH 1/2] Upgrade bcrypt to 5.0.0, pin marshmallow 4.3.0, add password byte-length validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgrade bcrypt 4.3.0 → 5.0.0 (raises ValueError for passwords >72 bytes instead of silently truncating; aligns with upstream security improvement) - Pin marshmallow >=4.3.0,<5 to ensure 4.3.0 is resolved rather than staying on 4.0.x - Remove unused datetime==4.9 (Zope backport, never imported anywhere) - Upgrade setuptools 80.10.2 → 82.0.1 (minor bump, no breaking changes) - Add max 72-byte password validation in PasswordService.validate_password_strength() - Add maxPasswordByteLength client-side validator (TextEncoder) to both Vue 2 and Vue 3 frontends, wired into all password creation/change forms - Update password field descriptions and error messages to reflect 6–72 character range Co-Authored-By: Claude Sonnet 4.6 --- client-v3/src/components/user/CreateUser.vue | 5 ++-- .../user/settings/ChangePassword.vue | 4 +-- .../src/composables/usePasswordValidation.ts | 3 +- client-v3/src/js/customValidators.ts | 2 ++ .../views/user/ForcePasswordChangeView.vue | 7 +++-- client/src/js/customValidators.ts | 2 ++ client/src/mixins/passwordValidation.ts | 2 ++ .../views/user/ForcePasswordChangeView.vue | 4 +-- client/src/vue_components/user/CreateUser.vue | 5 ++-- .../user/settings/ChangePassword.vue | 4 +-- server/requirements.txt | 7 ++--- server/services/password_service.py | 4 +++ server/test/services/test_password_service.py | 28 +++++++++++++++++++ 13 files changed, 59 insertions(+), 18 deletions(-) diff --git a/client-v3/src/components/user/CreateUser.vue b/client-v3/src/components/user/CreateUser.vue index 81f2e0b5..07cfece0 100644 --- a/client-v3/src/components/user/CreateUser.vue +++ b/client-v3/src/components/user/CreateUser.vue @@ -19,7 +19,7 @@ type="password" :state="fieldState('password')" /> - Required, at least 6 characters. + Required, between 6 and 72 characters. @@ -45,6 +45,7 @@ import { ref, computed } from 'vue'; import { useVuelidate } from '@vuelidate/core'; import { required, minLength, sameAs, helpers } from '@vuelidate/validators'; +import { maxPasswordByteLength } from '@/js/customValidators'; import { storeToRefs } from 'pinia'; import { useUserStore } from '@/stores/user'; @@ -78,7 +79,7 @@ const passwordRef = computed(() => state.value.password); const rules = { username: { required, isUsernameUnique }, - password: { required, minLength: minLength(6) }, + password: { required, minLength: minLength(6), maxPasswordByteLength }, confirmPassword: { required, sameAs: sameAs(passwordRef) }, }; diff --git a/client-v3/src/components/user/settings/ChangePassword.vue b/client-v3/src/components/user/settings/ChangePassword.vue index de50f57f..d15296a5 100644 --- a/client-v3/src/components/user/settings/ChangePassword.vue +++ b/client-v3/src/components/user/settings/ChangePassword.vue @@ -24,7 +24,7 @@ id="new-password-input-group" label="New Password" label-for="new-password-input" - description="Minimum 6 characters" + description="6–72 characters" > - This is a required field and must be at least 6 characters. + This is a required field and must be between 6 and 72 characters. diff --git a/client-v3/src/composables/usePasswordValidation.ts b/client-v3/src/composables/usePasswordValidation.ts index be354dff..c0e93c81 100644 --- a/client-v3/src/composables/usePasswordValidation.ts +++ b/client-v3/src/composables/usePasswordValidation.ts @@ -1,8 +1,9 @@ import { computed } from 'vue'; import { required, minLength, sameAs } from '@vuelidate/validators'; +import { maxPasswordByteLength } from '@/js/customValidators'; export function usePasswordValidation() { - const passwordRules = { required, minLength: minLength(6) }; + const passwordRules = { required, minLength: minLength(6), maxPasswordByteLength }; function confirmPasswordRules(getPasswordValue: () => string) { return computed(() => ({ required, sameAsPassword: sameAs(getPasswordValue()) })); diff --git a/client-v3/src/js/customValidators.ts b/client-v3/src/js/customValidators.ts index a30a944c..b4c78493 100644 --- a/client-v3/src/js/customValidators.ts +++ b/client-v3/src/js/customValidators.ts @@ -1,3 +1,5 @@ export const notNull = (value: unknown): boolean => value != null; export const notNullAndGreaterThanZero = (value: unknown): boolean => value != null && (value as number) > 0; +export const maxPasswordByteLength = (value: unknown): boolean => + typeof value !== 'string' || new TextEncoder().encode(value).length <= 72; diff --git a/client-v3/src/views/user/ForcePasswordChangeView.vue b/client-v3/src/views/user/ForcePasswordChangeView.vue index 69d61bfb..99dd0cfd 100644 --- a/client-v3/src/views/user/ForcePasswordChangeView.vue +++ b/client-v3/src/views/user/ForcePasswordChangeView.vue @@ -17,7 +17,7 @@ id="new-password-input-group" label="New Password" label-for="new-password-input" - description="Minimum 6 characters" + description="6–72 characters" > - This is a required field and must be at least 6 characters. + This is a required field and must be between 6 and 72 characters. @@ -75,6 +75,7 @@ import { ref, computed } from 'vue'; import { useRouter } from 'vue-router'; import { useVuelidate } from '@vuelidate/core'; import { required, minLength, sameAs } from '@vuelidate/validators'; +import { maxPasswordByteLength } from '@/js/customValidators'; import { useUserStore } from '@/stores/user'; import { useFormValidation } from '@/composables/useFormValidation'; @@ -86,7 +87,7 @@ const state = ref({ newPassword: '', confirmPassword: '' }); const loading = ref(false); const rules = computed(() => ({ - newPassword: { required, minLength: minLength(6) }, + newPassword: { required, minLength: minLength(6), maxPasswordByteLength }, confirmPassword: { required, sameAsPassword: sameAs(state.value.newPassword) }, })); diff --git a/client/src/js/customValidators.ts b/client/src/js/customValidators.ts index a30a944c..b4c78493 100644 --- a/client/src/js/customValidators.ts +++ b/client/src/js/customValidators.ts @@ -1,3 +1,5 @@ export const notNull = (value: unknown): boolean => value != null; export const notNullAndGreaterThanZero = (value: unknown): boolean => value != null && (value as number) > 0; +export const maxPasswordByteLength = (value: unknown): boolean => + typeof value !== 'string' || new TextEncoder().encode(value).length <= 72; diff --git a/client/src/mixins/passwordValidation.ts b/client/src/mixins/passwordValidation.ts index 8f9f3d5e..6442a07d 100644 --- a/client/src/mixins/passwordValidation.ts +++ b/client/src/mixins/passwordValidation.ts @@ -1,5 +1,6 @@ import { defineComponent } from 'vue'; import { required, minLength, sameAs } from 'vuelidate/lib/validators'; +import { maxPasswordByteLength } from '@/js/customValidators'; export default defineComponent({ methods: { @@ -12,6 +13,7 @@ export default defineComponent({ return { required, minLength: minLength(6), + maxPasswordByteLength, }; }, diff --git a/client/src/views/user/ForcePasswordChangeView.vue b/client/src/views/user/ForcePasswordChangeView.vue index ecb0dbe2..425921bc 100644 --- a/client/src/views/user/ForcePasswordChangeView.vue +++ b/client/src/views/user/ForcePasswordChangeView.vue @@ -21,7 +21,7 @@ id="new-password-input-group" label="New Password" label-for="new-password-input" - description="Minimum 6 characters" + description="6–72 characters" > - This is a required field and must be at least 6 characters. + This is a required field and must be between 6 and 72 characters. diff --git a/client/src/vue_components/user/CreateUser.vue b/client/src/vue_components/user/CreateUser.vue index 8cbd5cdf..3a529b4e 100644 --- a/client/src/vue_components/user/CreateUser.vue +++ b/client/src/vue_components/user/CreateUser.vue @@ -33,7 +33,7 @@ type="password" /> - This is a required field and must be at least 6 characters. + This is a required field and must be between 6 and 72 characters. - This is a required field and must be at least 6 characters. + This is a required field and must be between 6 and 72 characters. diff --git a/server/requirements.txt b/server/requirements.txt index 91816416..b0a944c1 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1,15 +1,14 @@ tornado==6.5.6 sqlalchemy>=2.0.50,<2.1.0 -datetime==4.9 python-dateutil==2.9.0.post0 marshmallow-sqlalchemy>=1.5.0 tornado-prometheus==0.1.2 -bcrypt==4.3.0 +bcrypt==5.0.0 anytree==2.13.0 alembic==1.18.4 -marshmallow<5 +marshmallow>=4.3.0,<5 pyjwt[crypto]==2.13.0 -setuptools==80.10.2 +setuptools==82.0.1 xkcdpass==1.30.0 zeroconf==0.149.16 python-jsonpath==2.0.2 \ No newline at end of file diff --git a/server/services/password_service.py b/server/services/password_service.py index ded36598..10c3286d 100644 --- a/server/services/password_service.py +++ b/server/services/password_service.py @@ -50,6 +50,7 @@ def validate_password_strength(password: str) -> tuple[bool, str]: Current requirements: - Minimum 6 characters + - Maximum 72 bytes when UTF-8 encoded (bcrypt hard limit) :param password: Password to validate :type password: str @@ -61,6 +62,9 @@ def validate_password_strength(password: str) -> tuple[bool, str]: if len(password) < 6: return False, "Password must be at least 6 characters long" + if len(password.encode("utf-8")) > 72: + return False, "Password must be 72 characters or fewer" + return True, "" @staticmethod diff --git a/server/test/services/test_password_service.py b/server/test/services/test_password_service.py index 5534a968..e7d917ca 100644 --- a/server/test/services/test_password_service.py +++ b/server/test/services/test_password_service.py @@ -78,6 +78,34 @@ def test_validate_password_strength_too_short(self): self.assertFalse(is_valid) self.assertEqual("Password must be at least 6 characters long", error_msg) + def test_validate_password_strength_at_72_byte_limit(self): + """Test that a password exactly 72 bytes long is accepted""" + # 72 ASCII characters = 72 bytes (boundary case) + password = "a" * 72 + is_valid, error_msg = PasswordService.validate_password_strength(password) + self.assertTrue(is_valid) + self.assertEqual("", error_msg) + + def test_validate_password_strength_exceeds_72_bytes(self): + """Test that passwords over 72 UTF-8 bytes are rejected""" + # 73 ASCII characters = 73 bytes + is_valid, error_msg = PasswordService.validate_password_strength("a" * 73) + self.assertFalse(is_valid) + self.assertEqual( + "Password must be 72 characters or fewer", error_msg + ) + + def test_validate_password_strength_multibyte_utf8_over_limit(self): + """Test that multi-byte UTF-8 characters are counted by byte length""" + # Each '€' (U+20AC) encodes to 3 bytes — 25 chars = 75 bytes, over the limit + password = "€" * 25 + self.assertGreater(len(password.encode("utf-8")), 72) + is_valid, error_msg = PasswordService.validate_password_strength(password) + self.assertFalse(is_valid) + self.assertEqual( + "Password must be 72 characters or fewer", error_msg + ) + def test_generate_temporary_password_default_word_count(self): """Test that generate_temporary_password produces 3-word password by default""" password = PasswordService.generate_temporary_password() From 0a6e1c38e49ae4c5a87a67b3f7be7ceb19a2f5dc Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sat, 30 May 2026 21:33:24 +0100 Subject: [PATCH 2/2] Fix ruff formatting in test_password_service.py Co-Authored-By: Claude Sonnet 4.6 --- server/test/services/test_password_service.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/server/test/services/test_password_service.py b/server/test/services/test_password_service.py index e7d917ca..079ed69a 100644 --- a/server/test/services/test_password_service.py +++ b/server/test/services/test_password_service.py @@ -91,9 +91,7 @@ def test_validate_password_strength_exceeds_72_bytes(self): # 73 ASCII characters = 73 bytes is_valid, error_msg = PasswordService.validate_password_strength("a" * 73) self.assertFalse(is_valid) - self.assertEqual( - "Password must be 72 characters or fewer", error_msg - ) + self.assertEqual("Password must be 72 characters or fewer", error_msg) def test_validate_password_strength_multibyte_utf8_over_limit(self): """Test that multi-byte UTF-8 characters are counted by byte length""" @@ -102,9 +100,7 @@ def test_validate_password_strength_multibyte_utf8_over_limit(self): self.assertGreater(len(password.encode("utf-8")), 72) is_valid, error_msg = PasswordService.validate_password_strength(password) self.assertFalse(is_valid) - self.assertEqual( - "Password must be 72 characters or fewer", error_msg - ) + self.assertEqual("Password must be 72 characters or fewer", error_msg) def test_generate_temporary_password_default_word_count(self): """Test that generate_temporary_password produces 3-word password by default"""