From cf28128fe3418bdae3836d2dece2f9f7717e61b4 Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Sun, 26 Jul 2026 05:10:22 +0700 Subject: [PATCH] fix(auth): cap passwords at the 72 bytes bcrypt can hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both sides advertised 8–100 characters, but bcrypt.GenerateFromPassword refuses anything over 72 bytes — and it runs after validation has passed. Creating a user with an 84-character password answered 500 Internal, and a 73-character password change answered a generic "New password does not meet requirements" that no field message explained. Validation now rejects over-long passwords as input, in bytes rather than runes so a 40-character multibyte passphrase cannot slip past a rune-counting limit and fail inside bcrypt anyway. The zod schema and the documented policy follow the same 72. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 2 +- backend/pkg/server/models/init.go | 17 +++++ backend/pkg/server/models/users.go | 4 +- backend/pkg/server/models/users_test.go | 65 +++++++++++++++++++ .../authentication/password-change-form.tsx | 5 +- 5 files changed, 89 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b92e1953..3a6da8ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 1. **Always use English** for all interactions, responses, explanations, and questions with users. 2. **Password Complexity Requirements**: For all password-related development (registration, password reset, API token generation, etc.), enforce the same policy in **both** backend and frontend — never rely on frontend validation alone. Source of truth, keep the two in sync: `backend/pkg/server/models/init.go` → `strongPasswordValidatorString` and `frontend/src/features/authentication/password-change-form.tsx` (zod schema). The policy: - - Length 8–100 characters. + - Length 8–72 characters (72 **bytes**, the most bcrypt will hash — a longer value fails inside `bcrypt.GenerateFromPassword`, after validation). - A password is valid if it is **either** 16+ characters (any composition), **or** 8–15 characters containing at least 1 lowercase letter, 1 uppercase letter, 1 number, and 1 special character from `!@#$&*`. ## Project Overview diff --git a/backend/pkg/server/models/init.go b/backend/pkg/server/models/init.go index 1847252d..9db52973 100644 --- a/backend/pkg/server/models/init.go +++ b/backend/pkg/server/models/init.go @@ -87,6 +87,22 @@ func strongPasswordValidatorString() validator.Func { } } +// MaxPasswordBytes is the longest password bcrypt.GenerateFromPassword accepts. +const MaxPasswordBytes = 72 + +func passwordLengthValidatorString() validator.Func { + return func(fl validator.FieldLevel) bool { + field := fl.Field() + + switch field.Kind() { + case reflect.String: + return len(field.String()) <= MaxPasswordBytes + default: + return false + } + } +} + var emailFormatRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) func isRealEmail(email string) bool { @@ -223,6 +239,7 @@ func init() { _ = validate.RegisterValidation("semver", templateValidatorString(semverRegexString)) _ = validate.RegisterValidation("semverex", templateValidatorString(semverexRegexString)) _ = validate.RegisterValidation("stpass", strongPasswordValidatorString()) + _ = validate.RegisterValidation("passlen", passwordLengthValidatorString()) _ = validate.RegisterValidation("vmail", emailValidatorString()) _ = validate.RegisterValidation("realemail", strictEmailValidatorString()) _ = validate.RegisterValidation("oauth_min_scope", oauthMinScope()) diff --git a/backend/pkg/server/models/users.go b/backend/pkg/server/models/users.go index d5486e4a..f53698b5 100644 --- a/backend/pkg/server/models/users.go +++ b/backend/pkg/server/models/users.go @@ -103,7 +103,7 @@ func (u User) Validate(db *gorm.DB) { // UserPassword is model to contain user information type UserPassword struct { - Password string `form:"password" json:"password" validate:"max=100,required" gorm:"column:password;type:TEXT"` + Password string `form:"password" json:"password" validate:"passlen,required" gorm:"column:password;type:TEXT"` User `form:"" json:""` } @@ -168,7 +168,7 @@ func (au AuthCallback) Valid() error { // nolint:lll type Password struct { CurrentPassword string `form:"current_password" json:"current_password" validate:"nefield=Password,min=5,max=100,required" gorm:"-"` - Password string `form:"password" json:"password" validate:"stpass,max=100,required" gorm:"type:TEXT"` + Password string `form:"password" json:"password" validate:"stpass,passlen,required" gorm:"type:TEXT"` ConfirmPassword string `form:"confirm_password" json:"confirm_password" validate:"eqfield=Password" gorm:"-"` } diff --git a/backend/pkg/server/models/users_test.go b/backend/pkg/server/models/users_test.go index 7c6fde29..55922957 100644 --- a/backend/pkg/server/models/users_test.go +++ b/backend/pkg/server/models/users_test.go @@ -1,6 +1,7 @@ package models import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -196,6 +197,24 @@ func TestPasswordValid(t *testing.T) { }, wantErr: true, }, + { + name: "new password at the bcrypt limit", + pw: Password{ + CurrentPassword: "OldPass1!abc", + Password: strings.Repeat("a", MaxPasswordBytes), + ConfirmPassword: strings.Repeat("a", MaxPasswordBytes), + }, + wantErr: false, + }, + { + name: "new password over the bcrypt limit", + pw: Password{ + CurrentPassword: "OldPass1!abc", + Password: strings.Repeat("a", MaxPasswordBytes+1), + ConfirmPassword: strings.Repeat("a", MaxPasswordBytes+1), + }, + wantErr: true, + }, } for _, tt := range tests { @@ -309,6 +328,52 @@ func TestUserPasswordValid(t *testing.T) { } assert.Error(t, up.Valid()) }) + + for _, tc := range []struct { + name string + length int + wantErr bool + }{ + {name: "password at the bcrypt limit", length: MaxPasswordBytes, wantErr: false}, + {name: "password over the bcrypt limit", length: MaxPasswordBytes + 1, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + up := UserPassword{ + Password: strings.Repeat("a", tc.length), + User: User{ + ID: 1, + Hash: "abcdef1234567890abcdef1234567890", + Type: UserTypeLocal, + Mail: "test@example.com", + Status: UserStatusActive, + RoleID: RoleUser, + }, + } + if tc.wantErr { + assert.Error(t, up.Valid()) + } else { + assert.NoError(t, up.Valid()) + } + }) + } + + t.Run("multibyte password within the rune count but over the byte limit", func(t *testing.T) { + t.Parallel() + up := UserPassword{ + // 40 runes, 80 bytes: a rune-counting `max=72` would let this through. + Password: strings.Repeat("é", 40), + User: User{ + ID: 1, + Hash: "abcdef1234567890abcdef1234567890", + Type: UserTypeLocal, + Mail: "test@example.com", + Status: UserStatusActive, + RoleID: RoleUser, + }, + } + assert.Error(t, up.Valid()) + }) } func TestUserPasswordTableName(t *testing.T) { diff --git a/frontend/src/features/authentication/password-change-form.tsx b/frontend/src/features/authentication/password-change-form.tsx index 96058bb1..218b7900 100644 --- a/frontend/src/features/authentication/password-change-form.tsx +++ b/frontend/src/features/authentication/password-change-form.tsx @@ -17,7 +17,10 @@ const passwordChangeSchema = z newPassword: z .string() .min(8, { message: 'Password must be at least 8 characters' }) - .max(100, { message: 'Password must not exceed 100 characters' }) + // bcrypt, which hashes it server-side, refuses anything longer than 72 bytes. + .refine((password) => new TextEncoder().encode(password).length <= 72, { + message: 'Password must not exceed 72 characters', + }) .refine( (password) => { if (password.length > 15) {