audit 3: backup CLI, envelope budget, directory sync, docs; findings of the reviews

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>
This commit is contained in:
mix
2026-10-15 10:38:39 +03:00
co-authored by Claude Opus 5.5
parent 508ac96c79
commit 242515c305
24 changed files with 901 additions and 90 deletions
+83 -13
View File
@@ -21,16 +21,21 @@
package main
import (
"bufio"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/mixeme/selfpost/internal/atomicfile"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/buildinfo"
"github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/web/validate"
)
// passwordEnv names the environment variable holding the encryption password.
@@ -70,13 +75,20 @@ func run(outPath, password string) error {
dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
dbPath := envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
w, closeOut, err := openOutput(outPath)
// The floor the panel holds the same files to: an encrypted backup is
// attacked offline, at leisure (security.md; audit 2026-10-12, M5). An
// empty password is no encryption, by design.
if password != "" && utf8.RuneCountInString(password) < validate.MinSecretFilePasswordLen {
return fmt.Errorf("the encryption password must be at least %d characters", validate.MinSecretFilePasswordLen)
}
w, err := openOutput(outPath)
if err != nil {
return err
}
defer closeOut()
defer w.abort()
sink := w
var sink io.Writer = w
var env *secretfile.Writer
if password != "" {
env, err = secretfile.NewWriter(w, secretfile.TypeFullBackup, password)
@@ -102,6 +114,9 @@ func run(outPath, password string) error {
return err
}
}
if err := w.commit(); err != nil {
return err
}
if outPath != "" {
kind := "plain"
if password != "" {
@@ -133,17 +148,28 @@ func runDecrypt(inPath, outPath, password string) error {
if err != nil {
return err
}
// The first chunk is opened before anything is written: a wrong password
// is told before the output is touched, and the envelope's type is
// authenticated only once a chunk opens (secretfile.Reader.Type; audit
// 2026-10-12, L1).
plain := bufio.NewReaderSize(env, 64<<10)
if _, err := plain.Peek(1); err != nil && !errors.Is(err, io.EOF) {
return err
}
if env.Type() != secretfile.TypeFullBackup {
return fmt.Errorf("that file is a %s, not a full backup", env.Type())
}
w, closeOut, err := openOutput(outPath)
w, err := openOutput(outPath)
if err != nil {
return err
}
defer closeOut()
defer w.abort()
if _, err := io.Copy(w, env); err != nil {
if _, err := io.Copy(w, plain); err != nil {
return err
}
if err := w.commit(); err != nil {
return err
}
if outPath != "" {
@@ -152,17 +178,61 @@ func runDecrypt(inPath, outPath, password string) error {
return nil
}
// openOutput returns stdout, or a freshly created owner-only file: both plain
// and encrypted backups are secret enough to keep off other users' eyes.
func openOutput(outPath string) (io.Writer, func(), error) {
// output is where the CLI writes: stdout, or a file that takes its name only
// once it is whole. A backup or a decryption that fails half way - a wrong
// password, an envelope cut short - leaves whatever had the name before, and
// no half-written file under it (audit 2026-10-12, L1 and stage 3). The file
// is the owner's alone (os.CreateTemp makes it 0600): plain and encrypted
// backups are secret enough to keep off other users' eyes.
type output struct {
io.Writer
tmp *os.File // nil for stdout
path string
}
func openOutput(outPath string) (*output, error) {
if outPath == "" {
return os.Stdout, func() {}, nil
return &output{Writer: os.Stdout}, nil
}
f, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
f, err := os.CreateTemp(filepath.Dir(outPath), "."+filepath.Base(outPath)+".tmp-*")
if err != nil {
return nil, nil, err
return nil, err
}
return f, func() { _ = f.Close() }, nil
return &output{Writer: f, tmp: f, path: outPath}, nil
}
// commit puts the whole file under its name.
func (o *output) commit() error {
if o.tmp == nil {
return nil
}
f := o.tmp
o.tmp = nil
if err := f.Sync(); err != nil {
f.Close()
os.Remove(f.Name())
return err
}
if err := f.Close(); err != nil {
os.Remove(f.Name())
return err
}
if err := os.Rename(f.Name(), o.path); err != nil {
os.Remove(f.Name())
return err
}
_ = atomicfile.SyncDir(filepath.Dir(o.path))
return nil
}
// abort removes a file that was not committed; after commit it does nothing.
func (o *output) abort() {
if o.tmp == nil {
return
}
o.tmp.Close()
os.Remove(o.tmp.Name())
o.tmp = nil
}
// readPassword takes the password from the given file (first line) or, when no
+73
View File
@@ -162,3 +162,76 @@ func TestReadPassword(t *testing.T) {
t.Error("-decrypt without a password was accepted")
}
}
// An encrypted backup is held to the panel's password floor (audit
// 2026-10-12, M5); a refused one writes nothing.
func TestEncryptionPasswordFloor(t *testing.T) {
seedProject(t)
out := filepath.Join(t.TempDir(), "backup.spbk")
err := run(out, "eleven-char")
if err == nil || !strings.Contains(err.Error(), "at least 12 characters") {
t.Fatalf("an 11-character password = %v, want refused", err)
}
if _, statErr := os.Stat(out); !os.IsNotExist(statErr) {
t.Errorf("the refused backup wrote %s", out)
}
}
// A wrong SELFPOST_DB_PATH makes no backup and no database (audit
// 2026-10-12, M3).
func TestBackupOfAMissingDatabaseIsRefused(t *testing.T) {
_, dataDir := seedProject(t)
missing := filepath.Join(dataDir, "typo.db")
t.Setenv("SELFPOST_DB_PATH", missing)
out := filepath.Join(t.TempDir(), "backup.tar.gz")
if err := run(out, ""); err == nil {
t.Fatal("a backup of a database that is not there was made")
}
for _, p := range []string{missing, out} {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("the refused backup left %s (stat err = %v)", p, err)
}
}
}
// -decrypt -o writes its file whole or not at all: a wrong password or an
// envelope cut short leaves the file that had the name before, and nothing
// else in its directory (audit 2026-10-12, L1 and stage 3).
func TestDecryptLeavesTheOutputAloneOnFailure(t *testing.T) {
seedProject(t)
dir := t.TempDir()
encrypted := filepath.Join(dir, "backup.spbk")
const password = "a long enough password"
if err := run(encrypted, password); err != nil {
t.Fatalf("create encrypted backup: %v", err)
}
sealed, err := os.ReadFile(encrypted)
if err != nil {
t.Fatal(err)
}
cut := filepath.Join(dir, "cut.spbk")
writeFile(t, cut, string(sealed[:len(sealed)-100]))
outDir := t.TempDir()
out := filepath.Join(outDir, "backup.tar.gz")
const before = "the archive that was here"
for _, c := range []struct{ name, in, password, want string }{
{"wrong password", encrypted, "the wrong password", "wrong password"},
{"cut short", cut, password, "corrupted"},
} {
t.Run(c.name, func(t *testing.T) {
writeFile(t, out, before)
err := runDecrypt(c.in, out, c.password)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("runDecrypt = %v, want %q", err, c.want)
}
if got, _ := os.ReadFile(out); string(got) != before {
t.Errorf("the failed decryption changed %s to %d bytes", out, len(got))
}
entries, _ := os.ReadDir(outDir)
if len(entries) != 1 {
t.Errorf("the failed decryption left files beside its output: %v", entries)
}
})
}
}