Steps 3-6 of stage 3 and what the two reviews found in steps 1-6, in one commit: the review fixes sit in the same hunks. Step 3, CLI backup (M3, M5, L1, L2; L4 of the stage's list): - backup.snapshotDB refuses a database that is not there and opens the source mode=ro (measured: mode=ro creates no file). - selfpost-backup holds an encrypting password to validate.MinSecretFilePasswordLen; -o of both directions writes a temporary file beside the name and renames it whole; -decrypt peeks the first chunk (password and type authenticated) before the output exists. - what a backup leaves out (a link under data/, a link under certs/ to a directory, a socket...) is told to OnWarn; certs/ with no file is backup.ErrNoCerts, refused by Check and Create, named by the panel. - parseTrustedProxies: a bare address takes /128 when written with a colon (::ffff:10.0.0.1/32 was ::/32). Step 4, secretfile (M4, L3): scrypt's V at most 128 MiB, r <= 64, p <= 2; bytes after the last chunk are ErrCorrupt. Step 5 (L7): atomicfile.SyncDir after the rename, used by Write, replaceSASLDB and the CLI; atomicfile_test.go. Step 6: security.md and guide on backup and restore. Reviews (technical, security): - H1: a restore of a server with DMARC on was refused on every start - the archive records no owner, tar as root left the DMARC maps root's, and the entrypoint only chowned the maps it named. It now hands every file beside queue/ in /data/postfix to panel:selfpost 0640; e2e writes an extra map before the backup and finds it panel:selfpost 640 after a root unpack. - the comparison reads regular files only (Lstat, size, LimitReader); a record unpacked again wins over the earlier check's mark; ConsumeRestore removes both; the mark is synced; a journal file beside a freshly unpacked database is refused. - AttachRefusalLine refuses an empty line as the old statement did; skipped entries named in words; -decrypt of an empty payload is no failure; guide wording on the shell's '>' and a killed CLI; the in-place restore's 'mv .' (older than this stage) fixed. Tested on the test server: go vet + go test of 25 packages, make e2e (all pass, 643 s) on this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
4.3 KiB
Go
136 lines
4.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mixeme/selfpost/internal/backup"
|
|
"github.com/mixeme/selfpost/internal/secretfile"
|
|
"github.com/mixeme/selfpost/internal/web/validate"
|
|
)
|
|
|
|
// postForm builds the kind of request the backup and export forms submit.
|
|
func postForm(values url.Values) *http.Request {
|
|
r := httptest.NewRequest(http.MethodPost, "/server/backup", strings.NewReader(values.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
return r
|
|
}
|
|
|
|
// The encryption password is only ever typed once into a file nobody can
|
|
// recover without it, so every way of getting it wrong has to be caught before
|
|
// the archive is sealed — and leaving the box unticked has to keep producing
|
|
// the plain archive earlier versions produced.
|
|
func TestSecretFilePassword(t *testing.T) {
|
|
long := strings.Repeat("x", validate.MinSecretFilePasswordLen)
|
|
short := strings.Repeat("x", validate.MinSecretFilePasswordLen-1)
|
|
|
|
tests := []struct {
|
|
name string
|
|
form url.Values
|
|
wantPass string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "unticked box means no encryption",
|
|
form: url.Values{"password": {long}, "password_confirm": {long}},
|
|
wantPass: "",
|
|
},
|
|
{
|
|
name: "ticked with a matching password",
|
|
form: url.Values{"encrypt": {"1"}, "password": {long}, "password_confirm": {long}},
|
|
wantPass: long,
|
|
},
|
|
{
|
|
name: "mistyped confirmation",
|
|
form: url.Values{"encrypt": {"1"}, "password": {long}, "password_confirm": {long + "!"}},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "too short",
|
|
form: url.Values{"encrypt": {"1"}, "password": {short}, "password_confirm": {short}},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "ticked but empty",
|
|
form: url.Values{"encrypt": {"1"}},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pass, errMsg := secretFilePassword(postForm(tt.form))
|
|
if tt.wantErr {
|
|
if errMsg == "" {
|
|
t.Fatalf("password %q accepted, want a rejection", pass)
|
|
}
|
|
if pass != "" {
|
|
t.Errorf("a rejected form still yielded password %q", pass)
|
|
}
|
|
return
|
|
}
|
|
if errMsg != "" {
|
|
t.Fatalf("unexpected rejection: %s", errMsg)
|
|
}
|
|
if pass != tt.wantPass {
|
|
t.Errorf("password = %q, want %q", pass, tt.wantPass)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The messages the import form shows must distinguish the operator's likely
|
|
// mistakes; a wrong password and a tampered file stay deliberately merged.
|
|
func TestDecryptErrorMessage(t *testing.T) {
|
|
tests := []struct {
|
|
err error
|
|
want string
|
|
}{
|
|
{secretfile.ErrWrongPassword, "Wrong password"},
|
|
{fmt.Errorf("read: %w", secretfile.ErrCorrupt), "damaged"},
|
|
{secretfile.ErrNotEncrypted, "not a SelfPost export"},
|
|
{errors.New("something else"), "Could not decrypt"},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := decryptErrorMessage(tt.err); !strings.Contains(got, tt.want) {
|
|
t.Errorf("decryptErrorMessage(%v) = %q, want it to mention %q", tt.err, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The encryption controls are shared markup pulled into two pages; a page that
|
|
// forgets to include the partial (or the data it needs) loses the option
|
|
// silently, since the plain download still works.
|
|
func TestBackupPageOffersEncryption(t *testing.T) {
|
|
h := &Handlers{view: mustView(t), cfg: Config{Version: "test"}}
|
|
rec := httptest.NewRecorder()
|
|
h.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/server/backup", nil),
|
|
http.StatusOK, "", "The two passwords do not match.")
|
|
|
|
body := rec.Body.String()
|
|
for _, want := range []string{
|
|
`name="encrypt"`, `name="password"`, `name="password_confirm"`,
|
|
`name="import_password"`, "data-encrypt-toggle", "data-encrypt-fields",
|
|
"data-import-password-fields",
|
|
fmt.Sprintf("at least %d characters", validate.MinSecretFilePasswordLen),
|
|
"The two passwords do not match.",
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("backup page is missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An empty certs/ is not "could not read": the page says what is missing and
|
|
// where it goes (review of audit stage 3).
|
|
func TestEmptyCertsMessage(t *testing.T) {
|
|
msg := unreadableBackupErr(fmt.Errorf("check: %w", backup.ErrNoCerts), "/selfpost-deploy")
|
|
if !strings.Contains(msg, "certs/ holds no certificate") || !strings.Contains(msg, "fullchain.pem") {
|
|
t.Errorf("message = %q", msg)
|
|
}
|
|
}
|