mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-24 03:56:32 +00:00
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:
co-authored by
Claude Opus 4.8
parent
fe812a77e2
commit
cf28128fe3
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:"-"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user