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
+3 -4
View File
@@ -12,6 +12,8 @@ import (
"path/filepath"
"strings"
"sync"
"github.com/mixeme/selfpost/internal/atomicfile"
)
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
@@ -118,10 +120,7 @@ func replaceSASLDB(path string, edit func(path string) error) error {
// loss may bring the old database back, out of step with the panel's
// records (review of stage 2c, F10). A directory that cannot be synced
// - not on every system - leaves the change as durable as before.
if dir, err := os.Open(filepath.Dir(path)); err == nil {
_ = dir.Sync()
dir.Close()
}
_ = atomicfile.SyncDir(filepath.Dir(path))
return nil
}
+23 -1
View File
@@ -1,3 +1,5 @@
// Package atomicfile writes a file so that readers see the old file or the
// new one, whole, and a power loss after Write returns keeps the new one.
package atomicfile
import (
@@ -7,7 +9,13 @@ import (
)
// Write writes data to path via a temp file in the same directory followed by
// a rename, so readers only ever see the complete old or new file.
// a rename, so readers only ever see the complete old or new file. The file
// is synced before the rename and the directory after it: the rename is a
// change of the directory, and without its own sync a power loss may bring
// the old file back (fsync(2): syncing a file "does not necessarily ensure
// that the entry in the directory containing the file has also reached
// disk"; audit 2026-10-12, L7). Every map the panel writes for Postfix and
// OpenDKIM goes through here.
func Write(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-*")
@@ -41,5 +49,19 @@ func Write(path string, data []byte, perm os.FileMode) error {
return fmt.Errorf("rename %s -> %s: %w", tmpName, path, err)
}
cleanup = false
_ = SyncDir(dir)
return nil
}
// SyncDir makes a rename in dir durable. It is best effort for its callers:
// the new file is in place and readable either way, and not every system
// syncs a directory - Go on Windows cannot open one for it - so a failure
// leaves the change as durable as it was before this existed.
func SyncDir(dir string) error {
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
+113
View File
@@ -0,0 +1,113 @@
package atomicfile
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// leftovers lists what is in dir besides the names given.
func leftovers(t *testing.T, dir string, names ...string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
keep := map[string]bool{}
for _, n := range names {
keep[n] = true
}
var out []string
for _, e := range entries {
if !keep[e.Name()] {
out = append(out, e.Name())
}
}
return out
}
// A new file and a replaced one hold what was written, with the mode asked
// for, and nothing is left beside them.
func TestWriteCreatesAndReplaces(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "map")
for _, body := range []string{"first\n", "second, longer than the first\n", ""} {
if err := Write(path, []byte(body), 0o640); err != nil {
t.Fatalf("Write %q: %v", body, err)
}
got, err := os.ReadFile(path)
if err != nil || string(got) != body {
t.Fatalf("file = %q, %v; want %q", got, err, body)
}
if runtime.GOOS != "windows" {
if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 {
t.Errorf("mode = %v, want 0640", info.Mode().Perm())
}
}
if rest := leftovers(t, dir, "map"); len(rest) != 0 {
t.Errorf("Write left %v", rest)
}
}
}
// A reader that opened the old file reads the old file to its end: the new
// one takes the name, it does not overwrite the old one's bytes.
func TestAReaderKeepsTheOldFile(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not rename over an open file; the container is Linux")
}
path := filepath.Join(t.TempDir(), "map")
if err := Write(path, []byte("old contents\n"), 0o640); err != nil {
t.Fatal(err)
}
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := Write(path, []byte("new\n"), 0o640); err != nil {
t.Fatal(err)
}
buf := make([]byte, 64)
n, _ := f.Read(buf)
if string(buf[:n]) != "old contents\n" {
t.Errorf("the open reader read %q", buf[:n])
}
}
// A write that fails leaves the old file and no temporary one.
func TestAFailedWriteLeavesTheOldFile(t *testing.T) {
dir := t.TempDir()
if err := Write(filepath.Join(dir, "missing", "map"), []byte("x"), 0o640); err == nil {
t.Error("Write into a directory that is not there succeeded")
}
// The name is a directory: the rename fails after the temporary file
// was written.
target := filepath.Join(dir, "taken")
if err := os.MkdirAll(filepath.Join(target, "inside"), 0o750); err != nil {
t.Fatal(err)
}
if err := Write(target, []byte("x"), 0o640); err == nil {
t.Error("Write over a directory succeeded")
}
if rest := leftovers(t, dir, "taken"); len(rest) != 0 {
t.Errorf("the failed Write left %v", rest)
}
if _, err := os.Stat(filepath.Join(target, "inside")); err != nil {
t.Errorf("the failed Write changed the directory in its way: %v", err)
}
}
// The directory is synced where the system allows it; on Linux, where the
// container runs, it does.
func TestSyncDir(t *testing.T) {
err := SyncDir(t.TempDir())
if runtime.GOOS == "linux" && err != nil {
t.Errorf("SyncDir on Linux = %v", err)
}
if err := SyncDir(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Error("SyncDir of a directory that is not there = nil")
}
}
+82 -15
View File
@@ -166,7 +166,7 @@ func Create(w io.Writer, p Params) error {
return err
}
if err := addTree(a, p.DataDir, DataArchivePrefix, excludedFromArchive, skipQueue, false); err != nil {
if _, err := addTree(a, p.DataDir, DataArchivePrefix, excludedFromArchive, skipQueue, false, p.OnWarn); err != nil {
return err
}
@@ -239,7 +239,7 @@ func Check(p Params) error {
if err := ValidateDeployRoot(p.DeployRoot); err != nil {
return err
}
if err := addTree(nil, p.DataDir, DataArchivePrefix, excludedFromArchive, skipQueue, false); err != nil {
if _, err := addTree(nil, p.DataDir, DataArchivePrefix, excludedFromArchive, skipQueue, false, nil); err != nil {
return err
}
return addDeployFiles(nil, p.DeployRoot, nil)
@@ -297,20 +297,43 @@ func addDeployFiles(a *archive, deployRoot string, onWarn func(string)) error {
// are links again, relative, into archive/ (eff-certbot docs, "Where are
// my certificates?"; assumptions C-Q4, C-A1). The archive carries the
// files they lead to, under the links' names.
return addTree(a, certsDir, CertsDirName+"/", nil, nil, true)
files, err := addTree(a, certsDir, CertsDirName+"/", nil, nil, true, onWarn)
if err != nil {
return err
}
// certs/ is what Postfix serves its certificate from (the compose file
// mounts it at /etc/postfix/tls). There and empty - or holding only what
// the archive cannot carry - it would give an archive that restores a
// server without its certificate, so there is none (audit 2026-10-12,
// L2).
if files == 0 {
return ErrNoCerts
}
return nil
}
// ErrNoCerts is a certs/ that is there with no file a backup can carry.
var ErrNoCerts = errors.New("backup: " + CertsDirName + "/ holds no file this backup can carry, so the server it restores would have no certificate - put the PEM files (or links to them) there")
// addTree walks root and adds every regular file (and directory, to preserve
// empty ones and modes) to a under archivePrefix + path relative to root.
// empty ones and modes) to a under archivePrefix + path relative to root, and
// returns how many files it took.
// When exclude is non-nil, top-level names relative to root are skipped; skip,
// when non-nil, leaves out whatever it says. With a nil a nothing is written:
// every file is opened and closed, which is how Check finds what cannot be read.
// Non-regular, non-directory entries (symlinks, sockets) are skipped - except,
// with follow, a symbolic link to a regular file, which is archived as that
// file under the link's name; a link that leads nowhere this process can
// reach is an error, as an unreadable file is.
func addTree(a *archive, root, archivePrefix string, exclude map[string]bool, skip skipFunc, follow bool) error {
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
// reach is an error, as an unreadable file is. Each entry left out that way
// is told to warn, when it is not nil: a backup does not leave something out
// without saying so (audit 2026-10-12, L2).
func addTree(a *archive, root, archivePrefix string, exclude map[string]bool, skip skipFunc, follow bool, warn func(string)) (files int, err error) {
skipped := func(name, what string) {
if warn != nil {
warn(name + " is " + what + ", not a file: the backup does not carry it")
}
}
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
@@ -362,6 +385,9 @@ func addTree(a *archive, root, archivePrefix string, exclude map[string]bool, sk
// the listing and the open: not part of /data's state.
return nil
}
if err == nil {
files++
}
return err
case follow && info.Mode()&fs.ModeSymlink != 0:
target, err := os.Stat(path)
@@ -369,13 +395,37 @@ func addTree(a *archive, root, archivePrefix string, exclude map[string]bool, sk
return fmt.Errorf("backup: %s is a link to a file this container cannot reach - with certbot, /etc/letsencrypt is mounted at the same path (guide § Reverse proxy): %w", name, err)
}
if !target.Mode().IsRegular() {
skipped(name, "a link to a directory")
return nil
}
return writeTarFile(a, name, target.Mode().Perm(), path)
if err := writeTarFile(a, name, target.Mode().Perm(), path); err != nil {
return err
}
files++
return nil
case info.Mode()&fs.ModeSymlink != 0:
skipped(name, "a link")
return nil
default:
skipped(name, kindOf(info.Mode()))
return nil
}
})
return files, err
}
// kindOf names what a non-regular, non-directory entry is, for a warning.
func kindOf(m fs.FileMode) string {
switch {
case m&fs.ModeSocket != 0:
return "a socket"
case m&fs.ModeNamedPipe != 0:
return "a named pipe"
case m&fs.ModeDevice != 0:
return "a device"
default:
return "neither a file nor a directory"
}
}
// writeTarBytes writes an in-memory file entry.
@@ -430,6 +480,21 @@ func writeTarFile(a *archive, name string, mode fs.FileMode, srcPath string) err
// applyQueryParams(): each _pragma is run as "pragma <value>" on connect,
// busy_timeout first.)
func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
// The database must be there: SQLite creates a file that is not, and an
// archive of that empty database restores a panel that offers its
// first-run setup on a server holding live keys (audit 2026-10-12, M3).
// mode=ro makes the open fail rather than create one, should the file go
// between the two: SQLite 3.53.4 creates a database only under "rwc", the
// default (https://sqlite.org/uri.html § 3.3), and VACUUM INTO is not a
// write to the source (lang_vacuum.html § 2.1, § 3) - both measured by
// TestSnapshotRefusesAMissingDatabase.
info, err := os.Stat(dbPath)
if err != nil {
return "", nil, fmt.Errorf("backup: the database: %w", err)
}
if !info.Mode().IsRegular() {
return "", nil, fmt.Errorf("backup: the database %s is not a file", dbPath)
}
dir, err := os.MkdirTemp("", "selfpost-backup-")
if err != nil {
return "", nil, fmt.Errorf("backup: temp dir: %w", err)
@@ -439,7 +504,7 @@ func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
// A short busy timeout lets VACUUM INTO wait out a brief writer rather than
// failing immediately if the panel happens to be mid-write.
db, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(5000)")
db, err := sql.Open("sqlite", "file:"+dbPath+"?mode=ro&_pragma=busy_timeout(5000)")
if err != nil {
cleanup()
return "", nil, fmt.Errorf("backup: open database: %w", err)
@@ -497,16 +562,18 @@ func CheckRestore(manifestPath, binaryVersion string) (restored bool, err error)
// ConsumeRestore ends a restore once its first start has done what a restore
// start does: the manifest is removed, so later starts (and in-place
// upgrades) are not gated by it, and then the record of the checked files. A
// start stopped between the two leaves the record alone, which nothing reads
// without a manifest and no backup carries.
// upgrades) are not gated by it, and then the record of the files, checked or
// unpacked again since. A start stopped between the two leaves the record
// alone, which nothing reads without a manifest and no backup carries.
func ConsumeRestore(manifestPath string) error {
if err := os.Remove(manifestPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("backup: consume restore manifest: %w", err)
}
checked := filepath.Join(filepath.Dir(manifestPath), contentsCheckedName)
if err := os.Remove(checked); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("backup: consume restore manifest: %w", err)
for _, name := range []string{contentsCheckedName, ContentsName} {
err := os.Remove(filepath.Join(filepath.Dir(manifestPath), name))
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("backup: consume restore manifest: %w", err)
}
}
return nil
}
+141
View File
@@ -5,6 +5,7 @@ import (
"bytes"
"compress/gzip"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
@@ -522,3 +523,143 @@ func TestCertsLinksAreFollowed(t *testing.T) {
t.Fatalf("Check with a dangling link: %v", err)
}
}
// A wrong SELFPOST_DB_PATH is refused, and nothing is created where it
// points: SQLite makes a database of a path that is not one, and an archive
// of that empty database restores a server that offers its first-run setup
// (audit 2026-10-12, M3). The snapshot opens the source read-only, which is
// what refuses it should the file go between the check and the open.
func TestSnapshotRefusesAMissingDatabase(t *testing.T) {
dataDir, _, deployRoot := seedDataDir(t)
missing := filepath.Join(dataDir, "elsewhere.db")
var buf bytes.Buffer
err := Create(&buf, Params{DataDir: dataDir, DBPath: missing, Version: "1.0.0", DeployRoot: deployRoot})
if err == nil {
t.Fatal("a backup of a database that is not there was made")
}
if _, statErr := os.Stat(missing); !os.IsNotExist(statErr) {
t.Fatalf("the refused backup created %s (stat err = %v)", missing, statErr)
}
db, err := sql.Open("sqlite", "file:"+missing+"?mode=ro")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := db.Ping(); err == nil {
t.Fatal("mode=ro opened a database that is not there")
}
if _, statErr := os.Stat(missing); !os.IsNotExist(statErr) {
t.Fatalf("mode=ro created %s (stat err = %v)", missing, statErr)
}
}
// certs/ there and empty is refused by Check and Create alike: the archive
// would restore a server without its certificate (audit 2026-10-12, L2).
func TestEmptyCertsIsRefused(t *testing.T) {
dataDir, dbPath, deployRoot := seedDataDir(t)
for _, f := range []string{"fullchain.pem", "privkey.pem"} {
os.Remove(filepath.Join(deployRoot, CertsDirName, f))
}
p := Params{DataDir: dataDir, DBPath: dbPath, Version: "1.0.0", DeployRoot: deployRoot}
if err := Check(p); err == nil || !strings.Contains(err.Error(), "certs/ holds no file") {
t.Errorf("Check with an empty certs/ = %v", err)
}
if err := Create(io.Discard, p); err == nil || !strings.Contains(err.Error(), "certs/ holds no file") {
t.Errorf("Create with an empty certs/ = %v", err)
}
}
// What a backup leaves out it says it leaves out: a link under data/, and a
// link under certs/ to a directory (audit 2026-10-12, L2).
func TestBackupWarnsOfWhatItLeavesOut(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symbolic links need privileges on Windows; the container is Linux")
}
dataDir, dbPath, deployRoot := seedDataDir(t)
if err := os.Symlink("sasl/sasldb2", filepath.Join(dataDir, "a-link")); err != nil {
t.Fatal(err)
}
if err := os.Symlink(t.TempDir(), filepath.Join(deployRoot, CertsDirName, "a-directory")); err != nil {
t.Fatal(err)
}
var warnings []string
p := Params{DataDir: dataDir, DBPath: dbPath, Version: "1.0.0", DeployRoot: deployRoot,
OnWarn: func(s string) { warnings = append(warnings, s) }}
if err := Create(io.Discard, p); err != nil {
t.Fatalf("Create: %v", err)
}
got := strings.Join(warnings, "\n")
for _, want := range []string{
"data/a-link is a link, not a file: the backup does not carry it",
"certs/a-directory is a link to a directory, not a file: the backup does not carry it",
} {
if !strings.Contains(got, want) {
t.Errorf("no warning %q in:\n%s", want, got)
}
}
}
// A link or a pipe where the record names a file is refused, not followed or
// read: a crafted archive must not make the start read outside /data, or
// wait (security review of audit stage 3).
func TestCheckRestoreReadsOnlyRegularFiles(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symbolic links need privileges on Windows; the container is Linux")
}
path := unpackData(t, createArchive(t, "1.0.0"))
dir := filepath.Dir(path)
sasl := filepath.Join(dir, "sasl", "sasldb2")
outside := filepath.Join(t.TempDir(), "outside")
writeFile(t, outside, "SASLDB")
os.Remove(sasl)
if err := os.Symlink(outside, sasl); err != nil {
t.Fatal(err)
}
if _, err := CheckRestore(path, "1.0.0"); err == nil || !strings.Contains(err.Error(), "data/sasl/sasldb2 is not the file") {
t.Fatalf("a link to a file of the same contents = %v, want refused", err)
}
}
// A journal file beside the restored database is from what the directory
// held before; SQLite would take it up, so the restore is refused.
func TestCheckRestoreRefusesAJournalBesideTheDatabase(t *testing.T) {
for _, side := range []string{"-wal", "-shm", "-journal"} {
path := unpackData(t, createArchive(t, "1.0.0"))
writeFile(t, filepath.Join(filepath.Dir(path), "selfpost.db"+side), "old")
if _, err := CheckRestore(path, "1.0.0"); err == nil || !strings.Contains(err.Error(), "selfpost.db"+side+" is beside the database") {
t.Errorf("%s beside the database = %v, want refused", side, err)
}
}
}
// The archive unpacked again after a start that checked it is checked again:
// its record wins over the mark of the earlier check, and a file it cut short
// is refused. A finished restore leaves neither record.
func TestARecordUnpackedAgainIsChecked(t *testing.T) {
archive := createArchive(t, "1.0.0")
path := unpackData(t, archive)
dir := filepath.Dir(path)
if _, err := CheckRestore(path, "1.0.0"); err != nil {
t.Fatal(err)
}
files := readArchive(t, archive)
writeFile(t, filepath.Join(dir, ContentsName), files[DataArchivePrefix+ContentsName])
writeFile(t, filepath.Join(dir, "sasl", "sasldb2"), "SAS")
if _, err := CheckRestore(path, "1.0.0"); err == nil || !strings.Contains(err.Error(), "data/sasl/sasldb2 is not the file") {
t.Fatalf("a second unpack cut short = %v, want refused", err)
}
writeFile(t, filepath.Join(dir, "sasl", "sasldb2"), files[DataArchivePrefix+"sasl/sasldb2"])
if _, err := CheckRestore(path, "1.0.0"); err != nil {
t.Fatalf("the second unpack whole: %v", err)
}
writeFile(t, filepath.Join(dir, ContentsName), files[DataArchivePrefix+ContentsName])
if err := ConsumeRestore(path); err != nil {
t.Fatal(err)
}
for _, name := range []string{ManifestName, ContentsName, contentsCheckedName} {
if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) {
t.Errorf("%s left after ConsumeRestore", name)
}
}
}
+44 -15
View File
@@ -12,6 +12,8 @@ import (
"path"
"path/filepath"
"strings"
"github.com/mixeme/selfpost/internal/atomicfile"
)
// FormatContents identifies the record of what an archive's data directory
@@ -93,34 +95,49 @@ func errUnpackedPart(detail string) error {
}
// checkContents compares the data directory with the archive's record, once
// per restore (see contentsCheckedName).
// per restore (see contentsCheckedName). A record unpacked anew wins over the
// mark of an earlier check: the archive was unpacked again, and that is what
// the start runs on (review of audit stage 3).
func checkContents(dataDir string) error {
checked := filepath.Join(dataDir, contentsCheckedName)
if c, err := readContents(checked); err == nil {
// A start before this one checked the files. It is the same restore
// when the manifest is still the one that record holds; a different
// archive unpacked over the directory since is checked anew.
if verifyFiles(dataDir, c, DataArchivePrefix+ManifestName) == nil {
return nil
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
path := filepath.Join(dataDir, ContentsName)
checked := filepath.Join(dataDir, contentsCheckedName)
c, err := readContents(path)
if errors.Is(err, os.ErrNotExist) {
prev, perr := readContents(checked)
if perr == nil && verifyFiles(dataDir, prev, DataArchivePrefix+ManifestName) == nil {
// A start before this one checked the files, and the manifest is
// still the one that record holds: the same restore, whose
// files that start may have changed since.
return nil
}
if perr != nil && !errors.Is(perr, os.ErrNotExist) {
return perr
}
return errUnpackedPart(DataArchivePrefix + ContentsName + ", its last entry, is not there")
}
if err != nil {
return err
}
// No backup carries the database's journal files. One beside the
// restored database is left from the database the directory held
// before, and opening this one would take it up: the first connection
// after a crash recovers from the WAL, and a checkpoint "takes content
// from the WAL file and transfers it back into the original database
// file" (https://sqlite.org/wal.html § 9, § 2.2).
for _, side := range []string{"-wal", "-shm", "-journal"} {
if _, err := os.Lstat(filepath.Join(dataDir, "selfpost.db"+side)); err == nil {
return errUnpackedPart("selfpost.db" + side + " is beside the database, and no archive holds one - it is left from what was in the directory before")
}
}
if err := verifyFiles(dataDir, c, ""); err != nil {
return err
}
if err := os.Rename(path, checked); err != nil {
return fmt.Errorf("backup: mark the restored files checked: %w", err)
}
// The start goes on to change the files; the mark must not be lost to a
// power cut after that, or the next start compares changed files.
_ = atomicfile.SyncDir(dataDir)
return nil
}
@@ -151,15 +168,27 @@ func verifyFiles(dataDir string, c Contents, only string) error {
if !ok || rel == "" || path.Clean(rel) != rel || rel == ".." || strings.HasPrefix(rel, "../") || path.IsAbs(rel) {
return errUnpackedPart("the record names " + f.Name + ", which is not a file of the data directory")
}
file, err := os.Open(filepath.Join(dataDir, filepath.FromSlash(rel)))
// The archive holds regular files only (archive.writeFile); a link
// or a pipe under the name is not followed or read - a crafted
// archive must not make the start read a file outside /data, or
// wait on a pipe (security review of audit stage 3).
full := filepath.Join(dataDir, filepath.FromSlash(rel))
info, err := os.Lstat(full)
if errors.Is(err, os.ErrNotExist) {
return errUnpackedPart(f.Name + " is missing")
}
if err != nil {
return fmt.Errorf("backup: check %s: %w", f.Name, err)
}
if !info.Mode().IsRegular() || info.Size() != f.Size {
return errUnpackedPart(f.Name + " is not the file the archive holds")
}
file, err := os.Open(full)
if err != nil {
return fmt.Errorf("backup: check %s: %w", f.Name, err)
}
h := sha256.New()
n, err := io.Copy(h, file)
n, err := io.Copy(h, io.LimitReader(file, f.Size+1))
file.Close()
if err != nil {
return fmt.Errorf("backup: check %s: %w", f.Name, err)
+27 -8
View File
@@ -105,12 +105,19 @@ const (
defaultR = 8
defaultP = 1
// maxLogN caps what a file may ask for, so a hostile header cannot make the
// reader allocate its way to an out-of-memory kill before it ever fails
// authentication.
maxLogN = 20
maxR = 32
maxP = 16
// What a file may ask of the reader, so a hostile header cannot make it
// allocate its way to an out-of-memory kill, or spin, before it ever fails
// authentication (audit 2026-10-12, M4). golang.org/x/crypto v0.58.0,
// scrypt.Key, allocates V, 128·r·N bytes (make([]uint32, 32*N*r)), B,
// p·128·r bytes of PBKDF2 output, and XY, 256·r; its work is N·r·p. V is
// held to four times what the writer asks (128·2^15·8 = 32 MiB), r to 64
// - the writer's is 8 - so B and XY stay under 32 KiB together (a header
// with N=2 and a huge r asked for 384 MiB in B and XY: security review of
// stage 3), p to 2 - the writer's is 1. The domain import reads a file
// any global user uploads, the CLI a file of any origin.
maxScryptMemory = 128 << 20
maxR = 64
maxP = 2
)
var (
@@ -180,8 +187,11 @@ func parseHeader(buf []byte) (*header, error) {
h.salt = buf[i : i+saltLen]
h.noncePrefix = buf[i+saltLen : i+saltLen+noncePrefixLen]
// Reject absurd work factors before spending any memory on them.
if h.logN < 1 || h.logN > maxLogN || h.r < 1 || h.r > maxR || h.p < 1 || h.p > maxP {
// Reject absurd work factors before spending any memory on them. logN
// under 31 and r under 65 keep the shift and the product in range; then
// the product is held to the budget.
if h.logN < 1 || h.logN > 30 || h.r < 1 || h.r > maxR || h.p < 1 || h.p > maxP ||
128*(uint64(1)<<h.logN)*uint64(h.r) > maxScryptMemory {
return nil, fmt.Errorf("%w: unreasonable key-derivation parameters", ErrCorrupt)
}
return h, nil
@@ -450,6 +460,15 @@ func (d *Reader) next() error {
if err != nil {
return ErrWrongPassword
}
// The last chunk ends the file: bytes after it - another envelope
// appended, say - are damage, not something to ignore (audit
// 2026-10-12, L3).
var more [1]byte
if n, err := io.ReadFull(d.r, more[:]); n > 0 {
return fmt.Errorf("%w: bytes after the last chunk", ErrCorrupt)
} else if err != nil && !errors.Is(err, io.EOF) {
return fmt.Errorf("secretfile: read chunk: %w", err)
}
d.done = true
}
d.plain = plain
+65 -1
View File
@@ -2,6 +2,7 @@ package secretfile
import (
"bytes"
"encoding/binary"
"errors"
"io"
"testing"
@@ -155,8 +156,71 @@ func TestEmptyPasswordRejected(t *testing.T) {
func TestUnreasonableKDFParamsRejected(t *testing.T) {
enc := encrypt(t, TypeFullBackup, "password password", []byte("x"))
bad := bytes.Clone(enc)
bad[MagicLen+2] = 40 // logN far beyond maxLogN
bad[MagicLen+2] = 40 // logN far beyond the budget
if _, err := NewReader(bytes.NewReader(bad), "password password"); !errors.Is(err, ErrCorrupt) {
t.Fatalf("err = %v, want ErrCorrupt", err)
}
}
// A header may ask for no more than the stated budget: 128·N·r bytes of
// scrypt's array at most 128 MiB, r at most 64, p at most 2 (audit
// 2026-10-12, M4; security review of stage 3). The writer's own parameters
// are a quarter of it.
func TestKDFBudget(t *testing.T) {
enc := encrypt(t, TypeFullBackup, "password password", []byte("x"))
header := func(logN uint8, r, p uint32) []byte {
h := bytes.Clone(enc[:headerLen])
h[MagicLen+2] = logN
binary.BigEndian.PutUint32(h[MagicLen+3:], r)
binary.BigEndian.PutUint32(h[MagicLen+7:], p)
return h
}
for _, c := range []struct {
logN uint8
r, p uint32
ok bool
}{
{defaultLogN, defaultR, defaultP, true},
{17, 8, 2, true}, // 128 MiB, the budget
{20, 1, 1, true}, // 128 MiB another way
{18, 8, 1, false}, // 256 MiB
{15, 64, 1, false}, // 256 MiB
{20, 32, 16, false}, // what the old cap let through: 4 GiB
{15, 8, 3, false}, // p over 2
{0, 8, 1, false}, // N of 1
{31, 1, 1, false}, // past the shift
{1, 1 << 21, 1, false}, // r alone over the budget
{1, 1 << 19, 2, false}, // V small, B and XY 384 MiB
{11, 64, 2, true}, // r at its cap
{11, 65, 1, false}, // r past it
{30, 1 << 20, 1, false}, // the largest product, still refused
{15, 0, 1, false},
{15, 8, 0, false},
} {
_, err := parseHeader(header(c.logN, c.r, c.p))
if c.ok && err != nil {
t.Errorf("logN=%d r=%d p=%d refused: %v", c.logN, c.r, c.p, err)
}
if !c.ok && !errors.Is(err, ErrCorrupt) {
t.Errorf("logN=%d r=%d p=%d = %v, want ErrCorrupt", c.logN, c.r, c.p, err)
}
}
}
// Nothing may follow the last chunk: two envelopes end to end, or stray
// bytes, are damage rather than the first file (audit 2026-10-12, L3).
func TestBytesAfterTheLastChunkRejected(t *testing.T) {
first := encrypt(t, TypeFullBackup, "password password", []byte("first"))
second := encrypt(t, TypeFullBackup, "password password", []byte("second"))
for name, data := range map[string][]byte{
"two envelopes": append(bytes.Clone(first), second...),
"one byte": append(bytes.Clone(first), 0),
} {
if _, _, err := decrypt(t, data, "password password"); !errors.Is(err, ErrCorrupt) {
t.Errorf("%s: err = %v, want ErrCorrupt", name, err)
}
}
if plain, _, err := decrypt(t, first, "password password"); err != nil || string(plain) != "first" {
t.Errorf("the envelope alone = %q, %v", plain, err)
}
}
+9 -6
View File
@@ -25,12 +25,15 @@ func explain(t *testing.T, s *Store, query string, args ...any) string {
return strings.Join(plan, "\n")
}
// The send log is the one table that grows with the mail, and every question
// asked of it on a path that runs per message, per log line or every few
// minutes is answered from an index: measured with EXPLAIN QUERY PLAN against
// the statements the store runs, not copies of them (audit 2026-10-12, M2;
// stage 3). SQLite chooses without statistics here, as it does on a server -
// nothing runs ANALYZE.
// The send log is the one table that grows with the mail. The questions
// asked of it by time or by scope - per message (the level-2 count), per log
// line (a refusal line), every few minutes (the sweep, the notifier), per
// page and per statistics view - are answered from an index: measured with
// EXPLAIN QUERY PLAN against the statements the store runs, not copies of
// them (audit 2026-10-12, M2; stage 3). Those by queue id (UpdateStatus,
// ExpireMessage, RecoverRefusal) go by idx_send_log_queue_id and are not
// pinned here. SQLite chooses without statistics here, as it does on a
// server - nothing runs ANALYZE.
func TestSendLogQueriesUseTheirIndexes(t *testing.T) {
s := openTestStore(t)
const since = "2026-10-01T00:00:00Z"
+6
View File
@@ -328,6 +328,12 @@ const attachRefusalLineQuery = `UPDATE send_log SET refusal_line = ?1 WHERE id =
// § 2); NOCASE folds ASCII only (datatype3.html § 7.1); ?1 is bound once and
// used twice (lang_expr.html § 4).
func (s *Store) AttachRefusalLine(clientIP, login, from string, at time.Time, line string) (bool, error) {
// An empty line is what "no line" is on a row; the statement's second
// term would let it through, the old statement did not (review of stage
// 3). No caller passes one.
if line == "" {
return false, nil
}
res, err := s.db.Exec(attachRefusalLineQuery,
line, ReasonLimit, ReasonClient, from, clientIP,
at.Add(-refusalLineWindow).UTC().Format(time.RFC3339),
+4
View File
@@ -345,6 +345,10 @@ func unreadableBackupErr(err error, deployRoot string) string {
return "The panel is not allowed to read " + name + ", and a backup without it would not bring the server back. " +
"On the host, give the file to group 998 and make it group-readable (chgrp 998, chmod 640), then try again. Nothing was downloaded."
}
if errors.Is(err, backup.ErrNoCerts) {
return "The project's certs/ holds no certificate, so a backup would bring the server back without one. " +
"On the host, put fullchain.pem and privkey.pem - or links to certbot's - in certs/, then try again. Nothing was downloaded."
}
return "Could not read everything a backup holds. Nothing was downloaded; the reason is in the panel's log."
}
@@ -9,6 +9,7 @@ import (
"strings"
"testing"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/web/validate"
)
@@ -123,3 +124,12 @@ func TestBackupPageOffersEncryption(t *testing.T) {
}
}
}
// 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)
}
}