fix(auth): cap passwords at the 72 bytes bcrypt can hash

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 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-26 05:10:22 +07:00
co-authored by Claude Opus 4.8
parent fe812a77e2
commit cf28128fe3
5 changed files with 89 additions and 4 deletions
+1 -1
View File
@@ -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 8100 characters.
- Length 872 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** 815 characters containing at least 1 lowercase letter, 1 uppercase letter, 1 number, and 1 special character from `!@#$&*`.
## Project Overview
+17
View File
@@ -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())
+2 -2
View File
@@ -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:"-"`
}
+65
View File
@@ -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) {
@@ -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) {