Skip to content
10 changes: 10 additions & 0 deletions api/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions api/password.go
Original file line number Diff line number Diff line change
@@ -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
}
38 changes: 38 additions & 0 deletions api/password_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
3 changes: 3 additions & 0 deletions api/signup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
6 changes: 6 additions & 0 deletions api/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions api/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions models/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions models/user_password_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading