diff --git a/api/admin.go b/api/admin.go index 34533a9f..14005b61 100644 --- a/api/admin.go +++ b/api/admin.go @@ -137,6 +137,12 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error { return err } + if params.Password != "" { + if err := validatePassword(params.Password); err != nil { + return err + } + } + err = a.db.Transaction(func(tx *storage.Connection) error { if params.Role != "" { if terr := user.SetRole(tx, params.Role); terr != nil { @@ -209,6 +215,10 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error { return err } + if err := validatePassword(params.Password); err != nil { + return err + } + aud := a.requestAud(ctx, r) if params.Aud != "" { aud = params.Aud diff --git a/api/password.go b/api/password.go new file mode 100644 index 00000000..bf99728d --- /dev/null +++ b/api/password.go @@ -0,0 +1,13 @@ +package api + +import "github.com/netlify/gotrue/models" + +// validatePassword rejects passwords that violate GoTrue's server-side rules. +// Empty passwords are accepted here; callers that require a non-empty password +// (e.g. signup) check that separately. +func validatePassword(password string) error { + if len(password) > models.MaxPasswordLength { + return unprocessableEntityError("Password exceeds the maximum length of %d bytes", models.MaxPasswordLength) + } + return nil +} diff --git a/api/password_test.go b/api/password_test.go new file mode 100644 index 00000000..ad06a291 --- /dev/null +++ b/api/password_test.go @@ -0,0 +1,38 @@ +package api + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidatePassword(t *testing.T) { + tests := []struct { + name string + password string + wantErr bool + }{ + {"empty is accepted here", "", false}, + {"short password", "hunter2", false}, + {"exactly 72 bytes", strings.Repeat("a", 72), false}, + {"73 bytes is rejected", strings.Repeat("a", 73), true}, + // 4-byte emoji * 19 = 76 bytes total + {"long emoji string rejected", strings.Repeat("😀", 19), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePassword(tt.password) + if tt.wantErr { + require.Error(t, err) + he, ok := err.(*HTTPError) + require.True(t, ok, "expected *HTTPError, got %T", err) + assert.Equal(t, 422, he.Code) + return + } + require.NoError(t, err) + }) + } +} diff --git a/api/signup.go b/api/signup.go index 886b3093..d5b683f9 100644 --- a/api/signup.go +++ b/api/signup.go @@ -37,6 +37,9 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { if params.Password == "" { return unprocessableEntityError("Signup requires a valid password") } + if err := validatePassword(params.Password); err != nil { + return err + } if err := a.validateEmail(ctx, params.Email); err != nil { return err } diff --git a/api/user.go b/api/user.go index 5bd96283..a7256d6c 100644 --- a/api/user.go +++ b/api/user.go @@ -62,6 +62,12 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { return badRequestError("Could not read User Update params: %v", err) } + if params.Password != "" { + if err := validatePassword(params.Password); err != nil { + return err + } + } + claims := getClaims(ctx) userID, err := uuid.FromString(claims.Subject) if err != nil { diff --git a/api/verify.go b/api/verify.go index 88c19b27..f71d0dd6 100644 --- a/api/verify.go +++ b/api/verify.go @@ -39,6 +39,12 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error { return unprocessableEntityError("Verify requires a token") } + if params.Password != "" { + if err := validatePassword(params.Password); err != nil { + return err + } + } + var ( user *models.User err error diff --git a/models/user.go b/models/user.go index 295caff3..d993e748 100644 --- a/models/user.go +++ b/models/user.go @@ -15,6 +15,16 @@ import ( const SystemUserID = "0" +// MaxPasswordLength is the largest password (in bytes) GoTrue will hash. +// bcrypt silently truncates any input beyond 72 bytes, so anything longer +// would only have its prefix validated on sign-in. CJK characters and emoji +// are multi-byte in UTF-8, so this is a byte limit rather than a rune limit. +const MaxPasswordLength = 72 + +// ErrPasswordTooLong is returned by hashPassword when the supplied password +// exceeds MaxPasswordLength. API handlers translate this into a 422 response. +var ErrPasswordTooLong = errors.New("password exceeds the maximum length of 72 bytes") + var SystemUserUUID = uuid.Nil // User respresents a registered user with email/password authentication @@ -230,6 +240,9 @@ func (u *User) SetEmail(tx *storage.Connection, email string) error { // hashPassword generates a hashed password from a plaintext string func hashPassword(password string) (string, error) { + if len(password) > MaxPasswordLength { + return "", ErrPasswordTooLong + } pw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", err diff --git a/models/user_password_test.go b/models/user_password_test.go new file mode 100644 index 00000000..e0bae856 --- /dev/null +++ b/models/user_password_test.go @@ -0,0 +1,20 @@ +package models + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHashPasswordRejectsOverlongInput(t *testing.T) { + _, err := hashPassword(strings.Repeat("a", MaxPasswordLength+1)) + require.Error(t, err) + assert.ErrorIs(t, err, ErrPasswordTooLong) +} + +func TestHashPasswordAcceptsBoundary(t *testing.T) { + _, err := hashPassword(strings.Repeat("a", MaxPasswordLength)) + require.NoError(t, err) +}