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:
@@ -234,7 +234,8 @@ var serverTopics = []HelpTopic{
|
||||
{ID: HelpBackup, Title: "Backup and moving a domain", Body: []Text{
|
||||
Rich("Full backup downloads one archive of the whole instance: ", Code("data/"), " (the database, DKIM keys and application credentials), ",
|
||||
Code("docker-compose.yml"), ", ", Code(".env"), " and ", Code("certs/"),
|
||||
". The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, "+
|
||||
". Both it and a domain's export ask for your account password - the one you sign in with - at every download, so a session left open is not enough to take them; wrong guesses count against the same limit as a password change."+
|
||||
" The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, "+
|
||||
"so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server "+
|
||||
"starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in ", Code("certs/"),
|
||||
" has to be readable by the panel: give it to group 10998 on the host (", Code("chgrp 10998"), ", ", Code("chmod 640"),
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestPanelJS(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skip("node is not installed")
|
||||
}
|
||||
for _, script := range []string{"poll_test.js", "unsaved_test.js"} {
|
||||
for _, script := range []string{"poll_test.js", "unsaved_test.js", "filename_test.js"} {
|
||||
t.Run(script, func(t *testing.T) {
|
||||
out, err := exec.Command(node, filepath.Join("testdata", script), filepath.Join("static", "panel.js")).CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
@@ -245,16 +245,34 @@
|
||||
});
|
||||
}
|
||||
|
||||
// A file field drawn as Bulma's .file (the import of a domain, mockup
|
||||
// backup.html): the input lies transparent over "Choose a file…", and the
|
||||
// name of the file chosen is written into .file-name, which says "No file
|
||||
// chosen" until then. Without this script the field still works; only the
|
||||
// name is not shown.
|
||||
function syncFileName(input) {
|
||||
var box = input.closest(".file");
|
||||
var name = box && box.querySelector(".file-name");
|
||||
if (name) {
|
||||
name.textContent = input.files && input.files[0] ? input.files[0].name : "No file chosen";
|
||||
}
|
||||
}
|
||||
|
||||
// One listener for the document rather than one per control: a form that
|
||||
// arrives in an htmx swap is covered without being wired up again.
|
||||
document.addEventListener("change", function (ev) {
|
||||
if (ev.target && ev.target.matches) {
|
||||
syncShowWhen(ev.target);
|
||||
if (ev.target.matches("input.file-input")) {
|
||||
syncFileName(ev.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// A page the browser restores from its history may keep the file chosen.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initShowWhen(document);
|
||||
document.querySelectorAll("input.file-input").forEach(syncFileName);
|
||||
});
|
||||
|
||||
// htmx:load fires for each top-level element htmx puts into the page, so
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
</div>
|
||||
<p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</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" required></div>
|
||||
<p class="help">The one you sign in with, asked at every download: the archive holds every secret of the server, and a session left open must not be enough to take it.</p></div>
|
||||
<button type="submit" class="button is-primary"><i class="ti ti-download"></i>Download full backup</button>
|
||||
</form>
|
||||
{{template "body_close"}}
|
||||
@@ -35,7 +37,7 @@
|
||||
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the domain's settings page.</p>
|
||||
<form class="sp-form" method="post" action="{{.ImportAction}}" enctype="multipart/form-data">
|
||||
<div class="field"><label class="label" for="importfile">Domain export file</label>
|
||||
<div class="control"><input class="input" id="importfile" name="file" type="file" accept=".json,.spde,application/json" required data-import-file></div></div>
|
||||
<div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" id="importfile" name="file" type="file" accept=".json,.spde,application/json" required data-import-file><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">No file chosen</span></label></div></div>
|
||||
<div class="field" data-import-password-fields><label class="label" for="importpw">Password</label>
|
||||
<div class="control"><input class="input" id="importpw" name="import_password" type="password" autocomplete="off"></div>
|
||||
<p class="help">Asked only for an encrypted <code>.spde</code> file.</p></div>
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
</div>
|
||||
<p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</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" required></div>
|
||||
<p class="help">The one you sign in with, asked at every export: the file holds the domain's DKIM key and its applications' credentials.</p></div>
|
||||
<button type="submit" class="button"><i class="ti ti-download"></i>Export domain</button>
|
||||
</form>
|
||||
{{template "body_close"}}
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
// The name of the file chosen in a Bulma .file field is written into its
|
||||
// .file-name (the import of a domain on Server › Backup): on change, and at
|
||||
// load for a page the browser restored with a file still chosen. Run by
|
||||
// TestPanelJS (paneljs_test.go): node filename_test.js <panel.js>. Exits
|
||||
// non-zero with what went wrong.
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
|
||||
const failures = [];
|
||||
function check(cond, what) {
|
||||
if (!cond) failures.push(what);
|
||||
}
|
||||
|
||||
const listeners = {};
|
||||
const label = { textContent: "No file chosen" };
|
||||
const box = { querySelector: (sel) => (sel === ".file-name" ? label : null) };
|
||||
const input = {
|
||||
files: [],
|
||||
matches: (sel) => sel === "input.file-input",
|
||||
closest: (sel) => (sel === ".file" ? box : null),
|
||||
};
|
||||
const document = {
|
||||
hidden: false,
|
||||
addEventListener: (type, fn) => (listeners[type] = listeners[type] || []).push(fn),
|
||||
body: { addEventListener() {} },
|
||||
getElementById: () => null,
|
||||
querySelectorAll: (sel) => (sel === "input.file-input" ? [input] : []),
|
||||
};
|
||||
vm.runInNewContext(fs.readFileSync(process.argv[2], "utf8"), {
|
||||
document, window: { addEventListener() {} }, htmx: { config: {}, ajax() {} }, setTimeout, clearTimeout, Date, Object, Math, console,
|
||||
});
|
||||
const fire = (type, ev) => (listeners[type] || []).forEach((fn) => fn(ev));
|
||||
|
||||
input.files = [{ name: "example.org.spde" }];
|
||||
fire("change", { target: input });
|
||||
check(label.textContent === "example.org.spde", `after choosing a file the name reads ${JSON.stringify(label.textContent)}`);
|
||||
|
||||
input.files = [];
|
||||
fire("change", { target: input });
|
||||
check(label.textContent === "No file chosen", `after clearing the choice the name reads ${JSON.stringify(label.textContent)}`);
|
||||
|
||||
input.files = [{ name: "restored.json" }];
|
||||
fire("DOMContentLoaded", {});
|
||||
check(label.textContent === "restored.json", `a restored page shows ${JSON.stringify(label.textContent)}`);
|
||||
|
||||
if (failures.length) {
|
||||
console.error(failures.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user