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

@@ -248,6 +248,81 @@ func TestRegisterEventPayload(t *testing.T) {
}
}
// TestRegisterIgnoresProtectedFields: a registration body sets only the
// RegisterFillable columns. Organisation membership, the IP columns and the
// account flags in the body are ignored, over HTTP and through RegisterUser,
// and the IP columns come only from the server-side address.
func TestRegisterIgnoresProtectedFields(t *testing.T) {
app, gdb := sessionApp(t, "0s")
disableRegisterThrottle(t, app)
org := &models.Organisation{Name: "Mass Assign Org", Slug: "mass-assign-org"}
if err := gdb.Create(org).Error; err != nil {
t.Fatal(err)
}
protected := func(email string) map[string]any {
return map[string]any{
"email": email, "password": "correct-horse", "password_confirmation": "correct-horse", "name": "Mal", "surname": "Lory",
"preferred_locale": "pl", "marketing_consent": true,
"organisation_id": org.ID, "organisation_role": "owner",
"created_ip_address": "203.0.113.9", "last_ip_address": "203.0.113.9",
"is_onboarded": true, "must_change_password": true,
}
}
check := func(t *testing.T, email string, wantIP *string) {
t.Helper()
var stored models.User
if err := gdb.Where("email = ?", email).Take(&stored).Error; err != nil {
t.Fatal(err)
}
if stored.Name == nil || *stored.Name != "Mal" || stored.Surname == nil || *stored.Surname != "Lory" ||
stored.PreferredLocale == nil || *stored.PreferredLocale != "pl" || !stored.MarketingConsent {
t.Fatalf("allowed columns not stored: %+v", stored)
}
if stored.OrganisationID != nil || stored.OrganisationRole != nil {
t.Fatalf("organisation from the body: id %v role %v", stored.OrganisationID, stored.OrganisationRole)
}
if stored.IsOnboarded || stored.MustChangePassword {
t.Fatalf("account flags from the body: onboarded %v must_change %v", stored.IsOnboarded, stored.MustChangePassword)
}
for name, got := range map[string]*string{"created_ip_address": stored.CreatedIPAddress, "last_ip_address": stored.LastIPAddress} {
switch {
case wantIP == nil && got != nil:
t.Fatalf("%s = %q, want NULL", name, *got)
case wantIP != nil && (got == nil || *got != *wantIP):
t.Fatalf("%s = %v, want %q", name, got, *wantIP)
}
}
}
t.Run("http", func(t *testing.T) {
body, err := json.Marshal(protected("mass-http@example.test"))
if err != nil {
t.Fatal(err)
}
rec := postRegister(t, app, "192.0.2.44", string(body))
if rec.Code != http.StatusOK {
t.Fatalf("register %d %s", rec.Code, rec.Body.String())
}
ip := "192.0.2.44"
check(t, "mass-http@example.test", &ip)
})
t.Run("RegisterUser with IP", func(t *testing.T) {
_, errs, err := controllers.RegisterUser(t.Context(), app, gdb, protected("mass-ip@example.test"), controllers.RegisterOptions{IP: "198.51.100.7"})
if err != nil || len(errs) != 0 {
t.Fatalf("RegisterUser = %v, %v", errs, err)
}
ip := "198.51.100.7"
check(t, "mass-ip@example.test", &ip)
})
t.Run("RegisterUser without IP", func(t *testing.T) {
_, errs, err := controllers.RegisterUser(t.Context(), app, gdb, protected("mass-noip@example.test"), controllers.RegisterOptions{})
if err != nil || len(errs) != 0 {
t.Fatalf("RegisterUser = %v, %v", errs, err)
}
check(t, "mass-noip@example.test", nil)
})
}
func TestRegisterImportDirection(t *testing.T) {
cmd := exec.Command("go", "list", "-deps", "./...")
cmd.Dir = moduleRoot(t)