diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d0be82..6a4c07d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +### Security + +- **A stolen `global` session no longer takes a full backup** (security + audit 2026-10-17, A2, high): the user form set the signed-in user's own + password without the current one, which then passed the backup's password + check. Adding a user, setting a password, changing an e-mail or a role on + the user form, and deleting a user now ask for your own account password, + as a backup does, under the same limit on wrong guesses. + ## [2.14.3] - 2026-10-17 A tag beside a label. No migration; schema 13. diff --git a/docs/guide.md b/docs/guide.md index a2b39aa..ab7cde1 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -719,6 +719,12 @@ The panel refuses to remove or demote the **last** global user, so it can never end up with none. A user cannot delete the account they are signed in with. The user form also sets the user's e-mail, which the users list shows. +Adding a user, setting a user's password, changing their e-mail or role - +your own account's included - and deleting a user ask for **your account +password**, the one you sign in with, as a backup does and under the same +limit on wrong guesses: a session left open is not enough to hand out an +account. Saving only the name or the domain assignments is not asked. + ### Sessions A login survives a container restart: sessions live in SQLite, not in diff --git a/docs/security-audit-2026-10-17.md b/docs/security-audit-2026-10-17.md index 587d7fe..e484f83 100644 --- a/docs/security-audit-2026-10-17.md +++ b/docs/security-audit-2026-10-17.md @@ -685,12 +685,14 @@ the argument. ### Stage 0 — the high and the cheap guards (~5 h) → patch release -- [ ] **A2.** `reauth` on user create, on a user update that carries +- [x] **A2.** `reauth` on user create, on a user update that carries `password` or changes `role`, and on user delete; the user form gets the `account_password` field the Backup page has; security.md § Backup and domain export says the user form is covered. Done when a test with a session and no password gets 401 on all three and the existing user-form - tests pass with the field. — **Opus**, 2 h. + tests pass with the field. — **Opus**, 2 h. *Done 2026-10-17:* also on a + change of e-mail (where reset links go); a save of the name and the + reach alone is not asked; TestUserFormAsksForTheAccountPassword. - [ ] **I2.** The three DMARC maps in `drop_links`; `[ -L ]` beside `[ -e ]` in postfix-config.sh:538,545; an e2e case plants a link and expects the log line. — **Opus**, 0.5 h. diff --git a/docs/security.md b/docs/security.md index cfe31bc..786a056 100644 --- a/docs/security.md +++ b/docs/security.md @@ -471,6 +471,16 @@ operator can act on it. file's own protection, not this one: whoever downloads it chooses that password. The import and the `selfpost-backup` CLI, which runs inside the container, are not asked. +- **Nor is it enough to make an account that would pass that check** + (security audit 2026-10-17, A2). Until `2.14.4` a `global` session could + set its own password on the user form - no current password asked, the + session kept - and then pass the check above with the password it chose, + or add a second global user with a password it knew. The user form now + asks for the same account password, under the same limiter, when it adds a + user and when a save sets a password, changes the e-mail (where reset + links go) or changes the role - the signed-in user's own account included - + and deleting a user asks for it too. A save of the name and the domain + assignments alone is not asked: neither reaches an account's secret. - Both files are secrets: a full backup carries DKIM keys, `sasldb2`, the panel users' password hashes, `docker-compose.yml`, `.env`, and the TLS private key from `certs/` when present; a domain export carries the DKIM key diff --git a/internal/web/handlers/handlers_delegation_test.go b/internal/web/handlers/handlers_delegation_test.go index 1d999a3..b5d336e 100644 --- a/internal/web/handlers/handlers_delegation_test.go +++ b/internal/web/handlers/handlers_delegation_test.go @@ -353,11 +353,12 @@ func TestUserFormAssignsBothDirections(t *testing.T) { out, _ := st.AddDomain("out.example.com", "mail") in, _ := st.AddInboundDomain("in.example.com") root := userWith(t, st, "root", store.RoleGlobal, store.Reach{}) + withAccountPassword(t, h, st, root) const password = "a-long-enough-password-1" create := func(username string, extra url.Values) (*httptest.ResponseRecorder, store.User) { t.Helper() - form := url.Values{"username": {username}, "password": {password}, "role": {"domain"}} + form := url.Values{"username": {username}, "password": {password}, "role": {"domain"}, "account_password": {testAccountPassword}} for k, v := range extra { form[k] = v } @@ -385,6 +386,7 @@ func TestUserFormAssignsBothDirections(t *testing.T) { update := func(u store.User, form url.Values) store.User { t.Helper() form.Set("username", u.Username) + form.Set("account_password", testAccountPassword) rec := send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)}, form) if rec.Code != http.StatusSeeOther { t.Fatalf("update %s = %d:\n%s", u.Username, rec.Code, rec.Body.String()) diff --git a/internal/web/handlers/handlers_dmarc_test.go b/internal/web/handlers/handlers_dmarc_test.go index ff8119a..7562ab4 100644 --- a/internal/web/handlers/handlers_dmarc_test.go +++ b/internal/web/handlers/handlers_dmarc_test.go @@ -286,7 +286,7 @@ func TestDeletingAUserLeavesDomainAddressesAndNamesNobody(t *testing.T) { lacks(t, name+" for the co-administrator", body, `boss-private@example.org`, `admin's`, `s default`, `Another user`) } - rec := send(s.h.HandleUserDelete, &globalPrincipal, "POST", "/server/users/"+idStr(ops.ID)+"/delete", map[string]string{"uid": idStr(ops.ID)}, url.Values{}) + rec := send(s.h.HandleUserDelete, &globalPrincipal, "POST", "/server/users/"+idStr(ops.ID)+"/delete", map[string]string{"uid": idStr(ops.ID)}, url.Values{"account_password": {"correct-password-here!"}}) if rec.Code != http.StatusSeeOther { t.Fatalf("deleting a user = %d:\n%s", rec.Code, rec.Body.String()) } diff --git a/internal/web/handlers/handlers_links_test.go b/internal/web/handlers/handlers_links_test.go index e1e246d..ac34693 100644 --- a/internal/web/handlers/handlers_links_test.go +++ b/internal/web/handlers/handlers_links_test.go @@ -18,10 +18,11 @@ func TestUserFormSendsANewAddressItsLink(t *testing.T) { ml := withMail(t, h) out1, _ := st.AddDomain("one.example.com", "mail") root := userWith(t, st, "root", store.RoleGlobal, store.Reach{}) + withAccountPassword(t, h, st, root) rec := send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, url.Values{ "username": {"ops"}, "email": {"ops@acme.io"}, "password": {"a-long-enough-password-1"}, "role": {"domain"}, - "domain_ids": {idStr(out1.ID)}, + "domain_ids": {idStr(out1.ID)}, "account_password": {testAccountPassword}, }) if rec.Code != http.StatusSeeOther { t.Fatalf("create = %d:\n%s", rec.Code, rec.Body.String()) @@ -42,7 +43,7 @@ func TestUserFormSendsANewAddressItsLink(t *testing.T) { } // The same address saved again keeps its mark and sends nothing; a new // one loses it and is sent its link. - form := url.Values{"username": {"ops"}, "role": {"domain"}, "domain_ids": {idStr(out1.ID)}, "email": {"ops@acme.io"}} + form := url.Values{"username": {"ops"}, "role": {"domain"}, "domain_ids": {idStr(out1.ID)}, "email": {"ops@acme.io"}, "account_password": {testAccountPassword}} if rec := send(h.HandleUserEdit, &root, "POST", path, pv, form); rec.Code != http.StatusSeeOther || len(sentBy(h, ml)) != 1 { t.Fatalf("the same address: %d, sent %d", rec.Code, len(sentBy(h, ml))) } diff --git a/internal/web/handlers/handlers_server_email_test.go b/internal/web/handlers/handlers_server_email_test.go index 6ac55c8..a4da4f6 100644 --- a/internal/web/handlers/handlers_server_email_test.go +++ b/internal/web/handlers/handlers_server_email_test.go @@ -16,10 +16,11 @@ func TestUserFormSetsTheAccountEmail(t *testing.T) { h, st := inboundHandlers(t) out1, _ := st.AddDomain("one.example.com", "mail") root := userWith(t, st, "root", store.RoleGlobal, store.Reach{}) + withAccountPassword(t, h, st, root) rec := send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, url.Values{ "username": {"ops"}, "email": {" ops@acme.io "}, "password": {"a-long-enough-password-1"}, "role": {"domain"}, - "domain_ids": {idStr(out1.ID)}, + "domain_ids": {idStr(out1.ID)}, "account_password": {testAccountPassword}, }) if rec.Code != http.StatusSeeOther { t.Fatalf("create with an e-mail = %d:\n%s", rec.Code, rec.Body.String()) @@ -32,7 +33,7 @@ func TestUserFormSetsTheAccountEmail(t *testing.T) { path, pv := "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)} post := func(extra url.Values) store.User { t.Helper() - form := url.Values{"username": {"ops"}, "role": {"domain"}, "domain_ids": {idStr(out1.ID)}} + form := url.Values{"username": {"ops"}, "role": {"domain"}, "domain_ids": {idStr(out1.ID)}, "account_password": {testAccountPassword}} for k, v := range extra { form[k] = v } @@ -54,7 +55,7 @@ func TestUserFormSetsTheAccountEmail(t *testing.T) { // Creating without one is fine. rec = send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, url.Values{ - "username": {"plain"}, "password": {"a-long-enough-password-1"}, "role": {"global"}}) + "username": {"plain"}, "password": {"a-long-enough-password-1"}, "role": {"global"}, "account_password": {testAccountPassword}}) if plain, _ := st.GetUserByUsername("plain"); rec.Code != http.StatusSeeOther || plain.Email != "" { t.Errorf("create without an e-mail = %d, %q", rec.Code, plain.Email) } diff --git a/internal/web/handlers/handlers_server_test.go b/internal/web/handlers/handlers_server_test.go index 79fb6a4..6064e6f 100644 --- a/internal/web/handlers/handlers_server_test.go +++ b/internal/web/handlers/handlers_server_test.go @@ -286,6 +286,7 @@ func TestUserDeletePageNamesTheAssignmentsAndRefusalsStayOnTheForm(t *testing.T) out1, _ := st.AddDomain("out.example.com", "mail") in1, _ := st.AddInboundDomain("in.example.com") root := userWith(t, st, "root", store.RoleGlobal, store.Reach{}) + withAccountPassword(t, h, st, root) u := userWith(t, st, "shop", store.RoleDomain, store.Reach{DomainIDs: []int64{out1.ID}, InboundDomainIDs: []int64{in1.ID}}) path := "/server/users/" + idStr(u.ID) + "/delete" @@ -294,6 +295,7 @@ func TestUserDeletePageNamesTheAssignmentsAndRefusalsStayOnTheForm(t *testing.T) `
Everyone here is an administrator; the role is the reach. Global also sees Server and is the only one who adds or deletes domains. The only global user cannot be changed to domain or deleted.
+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.
{{template "inline" .Note}}