user-reauth: the user form asks for your account password (audit A2)
Security audit 2026-10-17, finding A2 (high), stage 0; the mockup is
2c27275. A stolen global session could set its own password on the user
form - no current password asked, the session kept - and then pass the
backup's reauth with it, or add a second global user with a known
password. Now the user form calls reauth (the backup's check: bcrypt
against the signed-in user's own hash, under auth.AllowPasswordChange) when
it adds a user and when a save sets a password, changes the e-mail (reset
links go there) or changes the role, the own account included; deleting a
user asks too, refused on its own confirmation page (renderUserDelete, a
Flash on UserDelete). Name and reach alone are not asked. Missing or wrong:
401 on the same page, "Your account password is incorrect.", nothing saved.
Tests: TestUserFormAsksForTheAccountPassword (create, each of password,
e-mail, role, own password, reach alone, delete); the user-form tests send
the field (withAccountPassword gives root a real hash and h an auth
module). go vet, go test ./internal/web/... on Windows.
Docs: security.md § Backup and domain export, guide § Users, help word for
word with the mockup; CHANGELOG [Unreleased] Security; plan line ticked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -203,6 +203,12 @@ func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Assign at least one outbound or inbound domain to a domain administrator.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
||||
return
|
||||
}
|
||||
// A new user is an account whose password the creator knows: a session
|
||||
// alone must not be enough to make one (audit 2026-10-17, A2).
|
||||
if status, msg := h.reauth(r, "create user"); status != 0 {
|
||||
h.renderUserForm(w, r, status, 0, userFormView{FormErr: msg, FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
@@ -293,12 +299,25 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
|
||||
}
|
||||
}
|
||||
|
||||
hash := "" // empty keeps the stored hash (SaveUser)
|
||||
if password != "" {
|
||||
if err := validate.AdminPassword(password); err != nil {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
||||
return
|
||||
}
|
||||
}
|
||||
// A password, an e-mail (where reset links go) or a role set here is a way
|
||||
// to an account, the signed-in one's own included: it asks for the
|
||||
// signed-in user's password, as a backup does (audit 2026-10-17, A2).
|
||||
// Name and reach alone do not.
|
||||
if needsReauth(u, password, email, role) {
|
||||
if status, msg := h.reauth(r, "user "+strconv.FormatInt(u.ID, 10)); status != 0 {
|
||||
h.renderUserForm(w, r, status, u.ID, userFormView{FormErr: msg, FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
hash := "" // empty keeps the stored hash (SaveUser)
|
||||
if password != "" {
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
logf("panel: update user hash: %v", err)
|
||||
@@ -336,6 +355,12 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
|
||||
http.Redirect(w, r, "/server/users?done=updated", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// needsReauth is whether a user-form save changes what reaches the account:
|
||||
// its password, its e-mail (reset links go there) or its role.
|
||||
func needsReauth(u store.User, password, email string, role store.Role) bool {
|
||||
return password != "" || email != u.Email || role != u.Role
|
||||
}
|
||||
|
||||
// endSessionsAfterPasswordSet signs out the logins a user had under the old
|
||||
// password when an administrator sets a new one on the user form. An
|
||||
// administrator who sets their own password keeps the browser they are using.
|
||||
@@ -378,6 +403,13 @@ func (h *Handlers) HandleUserDeleteConfirm(w http.ResponseWriter, r *http.Reques
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.renderUserDelete(w, r, http.StatusOK, u, "")
|
||||
}
|
||||
|
||||
// renderUserDelete draws the confirmation, with a refusal above it when
|
||||
// errMsg is set.
|
||||
func (h *Handlers) renderUserDelete(w http.ResponseWriter, r *http.Request, status int, u store.User, errMsg string) {
|
||||
var err error
|
||||
in := view.UserDeleteInput{
|
||||
ID: u.ID, Username: u.Username, Global: u.Role == store.RoleGlobal,
|
||||
AllOutbound: u.AllDomains, AllInbound: u.AllInboundDomains,
|
||||
@@ -389,7 +421,8 @@ func (h *Handlers) HandleUserDeleteConfirm(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
}
|
||||
h.view.Render(w, http.StatusOK, "user-delete", view.NewUserDelete(h.shellMeta(r), in))
|
||||
in.Error = errMsg
|
||||
h.view.Render(w, status, "user-delete", view.NewUserDelete(h.shellMeta(r), in))
|
||||
}
|
||||
|
||||
// assignedNames names the domains a domain user is assigned one by one, outbound
|
||||
@@ -456,6 +489,12 @@ func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u st
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot delete your own account while signed in.", FormUsername: u.Username, FormEmail: u.Email, FormRole: string(u.Role)})
|
||||
return
|
||||
}
|
||||
// Removing an administrator asks for the signed-in user's password: a
|
||||
// session alone must not lock the others out (audit 2026-10-17, A2).
|
||||
if status, msg := h.reauth(r, "delete user "+strconv.FormatInt(u.ID, 10)); status != 0 {
|
||||
h.renderUserDelete(w, r, status, u, msg)
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteUser(u.ID); err != nil {
|
||||
if errors.Is(err, store.ErrLastGlobal) {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot delete the last global administrator.", FormUsername: u.Username, FormEmail: u.Email, FormRole: string(u.Role)})
|
||||
|
||||
Reference in New Issue
Block a user