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:
+10
-3
@@ -265,10 +265,17 @@ func parseTrustedProxies(raw string) []*net.IPNet {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(cidr, "/") {
|
||||
if ip := net.ParseIP(cidr); ip != nil && ip.To4() != nil {
|
||||
cidr += "/32"
|
||||
} else {
|
||||
// The prefix length goes with how the address is written: an
|
||||
// IPv4-mapped address written as IPv6 (::ffff:10.0.0.1) is a
|
||||
// 128-bit address, and "/32" on it is ::/32 - every address
|
||||
// whose first 32 bits are zero (::1 among them) trusted, and the
|
||||
// host meant not at all (audit 2026-10-12, L4). Go 1.26
|
||||
// net.IPNet.Contains compares an IPv4 client with the mapped
|
||||
// /128 as with 10.0.0.1/32 (net/ip.go, networkNumberAndMask).
|
||||
if strings.Contains(cidr, ":") {
|
||||
cidr += "/128"
|
||||
} else {
|
||||
cidr += "/32"
|
||||
}
|
||||
}
|
||||
_, n, err := net.ParseCIDR(cidr)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A bare address is one host however it is written; an IPv4-mapped one in
|
||||
// IPv6 notation used to become ::/32 (audit 2026-10-12, L4).
|
||||
func TestParseTrustedProxiesTakesABareAddressAsOneHost(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
entry string
|
||||
trusts []string
|
||||
refuses []string
|
||||
}{
|
||||
{"10.0.0.1", []string{"10.0.0.1"}, []string{"10.0.0.2"}},
|
||||
{"::ffff:10.0.0.1", []string{"10.0.0.1", "::ffff:10.0.0.1"}, []string{"10.0.0.2", "::1", "0:0:1::1"}},
|
||||
{"2001:db8::1", []string{"2001:db8::1"}, []string{"2001:db8::2"}},
|
||||
{"10.0.0.0/8", []string{"10.1.2.3"}, []string{"11.0.0.1"}},
|
||||
} {
|
||||
nets := parseTrustedProxies(c.entry)
|
||||
if len(nets) != 1 {
|
||||
t.Fatalf("%q parsed as %v", c.entry, nets)
|
||||
}
|
||||
for _, ip := range c.trusts {
|
||||
if !nets[0].Contains(net.ParseIP(ip)) {
|
||||
t.Errorf("%q (%v) does not trust %s", c.entry, nets[0], ip)
|
||||
}
|
||||
}
|
||||
for _, ip := range c.refuses {
|
||||
if nets[0].Contains(net.ParseIP(ip)) {
|
||||
t.Errorf("%q (%v) trusts %s", c.entry, nets[0], ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
-13
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user