fix: registration fills only an explicit allow-list and never takes the IP columns from the body

User.RegisterFillable lists what a registration body may set: name,
surname, email, password, preferred_locale and marketing_consent.
RegisterUser fills from it instead of Fillable, so organisation_id,
organisation_role, is_onboarded and must_change_password can no longer be
mass-assigned through POST /register, and created_ip_address and
last_ip_address come only from RegisterOptions.IP. Fillable is unchanged
for the admin form and trusted application code.
This commit is contained in:
Jakub Zych
2026-10-22 03:03:18 +02:00
parent 499b6dc408
commit b6b7ac7534
4 changed files with 121 additions and 17 deletions

View File

@@ -22,7 +22,9 @@ import (
// RegisterOptions tunes RegisterUser. Activate creates the account already
// activated (activate_mode auto, or a caller that activates explicitly).
// IP, when set, is recorded as created_ip_address and last_ip_address.
// IP, when set, is recorded as created_ip_address and last_ip_address; it is
// the only source of those columns, so with an empty IP they stay NULL
// whatever the fields say.
type RegisterOptions struct {
Activate bool
IP string
@@ -34,12 +36,16 @@ type RegisterOptions struct {
}
// RegisterUser validates fields against the user model's rules, hashes the
// password with golem15.user.password.bcrypt_cost (default 10), fills the
// fillable columns and creates the users row on db, which may be a
// transaction. Validation failures are returned as Laravel's errors object
// (attribute to messages) with a nil user and nothing written; they are not
// written to a response. fields is not modified. The register event is not
// fired: call FireRegisterEvent once the row is committed.
// password with golem15.user.password.bcrypt_cost (default 10), fills only
// the models.User RegisterFillable columns (name, surname, email, password,
// preferred_locale, marketing_consent) and creates the users row on db,
// which may be a transaction. Any other key in fields is ignored: an
// organisation membership and role are set with an explicit update after
// creation, as an application's onboarding does, and the IP columns come
// only from opts.IP. Validation failures are returned as Laravel's errors
// object (attribute to messages) with a nil user and nothing written; they
// are not written to a response. fields is not modified. The register event
// is not fired: call FireRegisterEvent once the row is committed.
func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields map[string]any, opts RegisterOptions) (*models.User, map[string][]string, error) {
if db == nil {
return nil, nil, errors.New("golem15.user: gorm handle is missing")
@@ -64,15 +70,18 @@ func RegisterUser(ctx context.Context, app *backpack.App, db *gorm.DB, fields ma
return nil, nil, err
}
input["password"] = hashed
if opts.IP != "" {
input["created_ip_address"] = opts.IP
input["last_ip_address"] = opts.IP
}
user := &models.User{}
production := app == nil || app.Config == nil || !app.Config.Bool("app.debug")
if err := lagoon.Fill(user, user.Fillable(), input, production); err != nil {
if err := lagoon.Fill(user, user.RegisterFillable(), input, production); err != nil {
return nil, nil, err
}
// The IP columns are server-set after the fill, so a body value can never
// reach them.
if opts.IP != "" {
created, last := opts.IP, opts.IP
user.CreatedIPAddress = &created
user.LastIPAddress = &last
}
user.HasSelfSetPassword = !opts.PasswordLess
if opts.Activate {
user.IsActivated = true