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:
mix
2026-10-17 21:28:18 +03:00
co-authored by Claude Opus 5.5
parent 2c27275970
commit a37aba6594
17 changed files with 246 additions and 24 deletions
+1 -1
View File
@@ -284,7 +284,7 @@ var serverTopics = []HelpTopic{
{ID: HelpUsers, Title: "Users and roles", Body: []Text{
Rich("The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); ",
Em("All"), " includes domains added later, and a domain user needs at least one domain or ", Em("All"),
" on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out everywhere; set for yourself, it keeps the session you set it from."),
" on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out everywhere; set for yourself, it keeps the session you set it from. Adding a user, changing a user's password, e-mail or role, and deleting a user ask for your own account password, as a backup does, with the same limit on wrong guesses."),
}},
{ID: HelpSettings, Title: "Settings", Body: []Text{
Rich("Settings holds what is true of the whole instance. The time zone is the container's, ", Code("TZ"), " in ", Code(".env"),
+7 -1
View File
@@ -341,7 +341,8 @@ func NewUserForm(m Meta, in UserFormInput) *UserForm {
// what goes with them, the one button that does it, and the way out.
type UserDelete struct {
Meta
Head Head
Head Head
Flash *Flash
Danger Box
Intro Text
@@ -362,6 +363,8 @@ type UserDeleteInput struct {
AllInbound bool
Outbound []string
Inbound []string
// Error is a refusal of the confirmation (a wrong account password).
Error string
}
// NewUserDelete builds the page.
@@ -384,6 +387,9 @@ func NewUserDelete(m Meta, in UserDeleteInput) *UserDelete {
Confirm: "Delete " + in.Username,
KeepHref: href,
}
if in.Error != "" {
p.Flash = &Flash{Error: true, Text: Plain(in.Error)}
}
if in.Global {
p.Consequences = append(p.Consequences, Plain("remove their global access to the server and to every domain."))
return p
+4 -1
View File
@@ -4,7 +4,8 @@
the lists while the role is Global (data-global-role on the select,
data-domain-pick on the boxes); without scripts they stay in view and the
server ignores them for the global role. A blank password keeps the current
one. */}}
one. Your account password is asked for a new user and for a change of
password, e-mail or role (audit 2026-10-17, A2); the server decides. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
@@ -25,6 +26,8 @@
<option value="{{.Value}}"{{if eq .Value $sel}} selected{{end}}>{{.Label}}</option>{{end}}
</select></div></div>
<p class="help">Everyone here is an administrator; the role is the reach. <em>Global</em> also sees Server and is the only one who adds or deletes domains. The only global user cannot be changed to <em>domain</em> or deleted.</p></div>
<div class="field"><label class="label" for="acpw">Your account password</label><div class="control"><input class="input" id="acpw" name="account_password" type="password" autocomplete="current-password"{{if .NewUser}} required{{end}}></div>
<p class="help">Yours, the one you sign in with, not this user's. Asked when you add a user, or change a password, an e-mail or a role here: a session left open must not be enough to hand out an account.</p></div>
{{template "body_close"}}
{{template "box_close"}}
</div>
@@ -2,6 +2,7 @@
one button that does it, and the way back to the user's form. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
<div class="columns">
<div class="column is-7">
@@ -11,6 +12,8 @@
{{template "confirm_list" .Consequences}}
<p class="sp-muted mb-4">{{template "inline" .Note}}</p>
<form method="post" action="{{.Action}}">
<div class="field"><label class="label" for="acpw">Your account password</label><div class="control"><input class="input" id="acpw" name="account_password" type="password" autocomplete="current-password" required></div>
<p class="help">The one you sign in with: a session left open must not be enough to remove an administrator.</p></div>
<div class="buttons">
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>{{.Confirm}}</button>
<a class="button is-text" href="{{.KeepHref}}">Keep it</a>