backup-reauth: the account password before a backup or an export
Agreed with the owner on 2026-10-13, taken up on 2026-10-16 ("Да"); the
mockup is 1888dce. Both secret downloads - the full backup on Server ›
Backup and a domain's export on its settings - ask for "Your account
password": reauth (handlers_backup.go) checks the signed-in user's own
password with bcrypt, as a password change does, under the same limiter
(auth.AllowPasswordChange: ten in fifteen minutes per user, shared with the
Account form). A wrong or missing one is 401 on the same page with "Your
account password is incorrect.", logged; nothing is sent. The import and
the selfpost-backup CLI are not asked.
Also, the owner's phone screenshot of 2026-10-16 ("кнопка выбора файла не
такая, как в макете. Это косяк?"): the import's file field was a bare
<input type="file"> drawn by the browser ("Browse…"), where the mockup has
Bulma's .file - "Choose a file…" and the name chosen. The template now
follows the mockup; panel.js writes the name into .file-name on change and
at load (testdata/filename_test.js, run by TestPanelJS). No other file
field exists.
Tests: TestSecretDownloadsAskForTheAccountPassword (missing, wrong, right,
the limiter), the export refusals of the settings page with the password,
cmd/panel's restore tests and e2e's backups with it, e2e's refusal of a
backup without and with a wrong password. go vet + go test (26 packages)
and make e2e on the test server for this tree.
Docs: security.md § Backup and domain export, guide § Full backup and
restore, help (word for word with the mockup); CHANGELOG [Unreleased];
roadmap row done.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// maxImportBytes caps a domain-import upload. A domain export is a small JSON
|
||||
@@ -65,6 +67,10 @@ func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if status, msg := h.reauth(r, "full backup"); status != 0 {
|
||||
h.renderBackupPageWith(w, r, status, "", msg)
|
||||
return
|
||||
}
|
||||
password, pwErr := secretFilePassword(r)
|
||||
if pwErr != "" {
|
||||
h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
|
||||
@@ -161,6 +167,10 @@ func (h *Handlers) HandleExportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if status, msg := h.reauth(r, "export of domain "+strconv.FormatInt(d.ID, 10)); status != 0 {
|
||||
h.renderDomainSettings(w, r, status, d, settingsView{Err: msg})
|
||||
return
|
||||
}
|
||||
password, pwErr := secretFilePassword(r)
|
||||
if pwErr != "" {
|
||||
h.renderDomainSettings(w, r, http.StatusBadRequest, d, settingsView{Err: pwErr})
|
||||
@@ -314,6 +324,38 @@ func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
// form. The confirmation field is checked here rather than in the browser
|
||||
// because a typo in an encryption password is unrecoverable: the archive would
|
||||
// be sealed with a secret the operator does not know.
|
||||
// reauth checks the account password a secret download asks for
|
||||
// (backup-reauth): the full backup holds the whole database - the panel's
|
||||
// password hashes, every application's SASL password, the DKIM keys - and a
|
||||
// domain's export its key and its applications' credentials, so a session
|
||||
// alone, stolen or left open, must not be enough to take them. The password
|
||||
// is the signed-in user's own, checked as a password change checks it and
|
||||
// under the same limiter (auth.AllowPasswordChange), so the form is no place
|
||||
// to guess one. It returns 0 when it holds, else the status and the message
|
||||
// for the form. what names the download in the log.
|
||||
func (h *Handlers) reauth(r *http.Request, what string) (int, string) {
|
||||
p, ok := h.principal(r)
|
||||
if !ok {
|
||||
return http.StatusUnauthorized, "Sign in again."
|
||||
}
|
||||
if !h.auth.AllowPasswordChange(p.ID) {
|
||||
return http.StatusTooManyRequests, "Too many attempts. Please wait and try again."
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return http.StatusBadRequest, "Invalid form submission."
|
||||
}
|
||||
u, err := h.store.GetUser(p.ID)
|
||||
if err != nil {
|
||||
logf("panel: %s: get user %d: %v", what, p.ID, err)
|
||||
return http.StatusInternalServerError, "Internal error. Please try again."
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(r.PostFormValue("account_password"))); err != nil {
|
||||
logf("panel: %s: user %d gave a wrong account password", what, p.ID)
|
||||
return http.StatusUnauthorized, "Your account password is incorrect."
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
func secretFilePassword(r *http.Request) (password, errMsg string) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return "", "Invalid form submission."
|
||||
|
||||
@@ -133,3 +133,42 @@ func TestEmptyCertsMessage(t *testing.T) {
|
||||
t.Errorf("message = %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// A session is not enough for a secret download (backup-reauth): the full
|
||||
// backup and a domain's export ask for the signed-in user's own password.
|
||||
// Wrong or missing, nothing is sent and the page says so; right, the request
|
||||
// goes on to its own checks. Guesses share the password change's limiter.
|
||||
func TestSecretDownloadsAskForTheAccountPassword(t *testing.T) {
|
||||
s := newAppStand(t)
|
||||
exportPath := "/outbound/domains/" + idStr(s.d.ID) + "/settings/export"
|
||||
for _, pw := range []string{"", "wrong-password-entirely"} {
|
||||
rec := postFormAs(s.h.HandleExportDomain, globalPrincipal, exportPath, s.paths, url.Values{"account_password": {pw}})
|
||||
if rec.Code != http.StatusUnauthorized || rec.Header().Get("Content-Disposition") != "" ||
|
||||
!strings.Contains(rec.Body.String(), "Your account password is incorrect.") {
|
||||
t.Fatalf("export with account password %q = %d, %q:\n%.600s", pw, rec.Code, rec.Header().Get("Content-Disposition"), rec.Body.String())
|
||||
}
|
||||
rec = postFormAs(s.h.HandleBackup, globalPrincipal, "/server/backup", nil, url.Values{"account_password": {pw}})
|
||||
if rec.Code != http.StatusUnauthorized || rec.Header().Get("Content-Disposition") != "" ||
|
||||
!strings.Contains(rec.Body.String(), "Your account password is incorrect.") ||
|
||||
!strings.Contains(rec.Body.String(), `name="account_password" type="password" autocomplete="current-password" required`) {
|
||||
t.Fatalf("backup with account password %q = %d:\n%.600s", pw, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The right password lets the export through: a plain JSON download.
|
||||
rec := postFormAs(s.h.HandleExportDomain, globalPrincipal, exportPath, s.paths, url.Values{"account_password": {"correct-password-here!"}})
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Header().Get("Content-Disposition"), "selfpost-domain-example.org.json") {
|
||||
t.Fatalf("export with the right password = %d, %q:\n%.600s", rec.Code, rec.Header().Get("Content-Disposition"), rec.Body.String())
|
||||
}
|
||||
|
||||
// Five checks were made above, and the limiter allows ten in its window:
|
||||
// after six more, a check is refused before the password is looked at,
|
||||
// the right one included.
|
||||
for i := 0; i < 6; i++ {
|
||||
postFormAs(s.h.HandleBackup, globalPrincipal, "/server/backup", nil, url.Values{"account_password": {"guess"}})
|
||||
}
|
||||
rec = postFormAs(s.h.HandleExportDomain, globalPrincipal, exportPath, s.paths, url.Values{"account_password": {"correct-password-here!"}})
|
||||
if rec.Code != http.StatusTooManyRequests || !strings.Contains(rec.Body.String(), "Too many attempts.") {
|
||||
t.Fatalf("after ten checks = %d:\n%.600s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,12 +189,12 @@ func TestDomainSettingsRefusalsStayOnTheSettingsPage(t *testing.T) {
|
||||
}
|
||||
has(t, "report address refusal", body, `Choose how aggregate reports are addressed for this domain.`)
|
||||
|
||||
code, body = post(s.h.HandleExportDomain, "export", url.Values{"encrypt": {"1"}, "password": {"short"}, "password_confirm": {"short"}})
|
||||
code, body = post(s.h.HandleExportDomain, "export", url.Values{"encrypt": {"1"}, "password": {"short"}, "password_confirm": {"short"}, "account_password": {"correct-password-here!"}})
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("a short export password = %d", code)
|
||||
}
|
||||
has(t, "export refusal", body, `The encryption password must be at least 12 characters.`, `<h1 class="title is-3">Domain settings</h1>`)
|
||||
code, body = post(s.h.HandleExportDomain, "export", url.Values{"encrypt": {"1"}, "password": {"long-enough-password"}, "password_confirm": {"another-one-entirely"}})
|
||||
code, body = post(s.h.HandleExportDomain, "export", url.Values{"encrypt": {"1"}, "password": {"long-enough-password"}, "password_confirm": {"another-one-entirely"}, "account_password": {"correct-password-here!"}})
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("export passwords that differ = %d", code)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user