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
+30 -2
View File
@@ -308,6 +308,23 @@ operator can act on it.
that the panel's user may read. `certs/` is the operator's, mounted
read-only; only someone who can write the project directory places a link
there, and only a global user can download the archive (`2.5.2`).
What a backup still leaves out - a link under `data/`, a link under
`certs/` to a directory - it names in a warning, and a `certs/` with no
file in it is refused rather than archived empty (`2.5.3`).
- A backup is made only of the database that is there: a wrong
`SELFPOST_DB_PATH` is refused, and the snapshot opens the source
read-only. An archive of an empty database would restore a server that
offers its first-run setup - a new global administrator - while it holds
live DKIM keys and `sasldb2` (`2.5.3`).
- The first start after a restore opens nothing until `/data` matches the
archive's last entry, `data/contents.json` (size and SHA-256 of each file
under `data/`): an archive unpacked in part - its manifest there, half its
database - is refused (`2.5.3`). The check is integrity, not authenticity:
whoever can write the project directory can write that record too. It
reads only regular files at the recorded names (a link or a pipe there is
refused, not followed), refuses a database journal file beside the
restored database, and is made once per restore: a start after one that
passed it and failed is not compared again while the manifest is the same.
- Since 2.2.0 a full backup also carries, in the database, what Postfix logged
about each message still in the send log (`send_log_lines`): the submitting
client's address and SASL login, the message id, the receiving server and
@@ -318,7 +335,12 @@ operator can act on it.
scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each
authenticated with the header, the chunk number, and an end-of-stream flag —
a truncated or substituted file fails to open instead of silently restoring a
partial "tail". Format and wrapper:
partial "tail", and bytes after the last chunk are refused (`2.5.3`). The
key-derivation parameters travel in the header, so a reader is bounded in
what a header may ask before the password is checked: scrypt's array at
most 128 MiB (four times what SelfPost writes), r ≤ 64 so the rest stays
under 32 KiB, p ≤ 2 - *Import a domain* reads an uploaded file, and the
old cap let a header ask for 4 GiB (`2.5.3`). Format and wrapper:
[internal/secretfile](../internal/secretfile/secretfile.go).
- Extensions: `.spbk` (**S**elf**P**ost **b**ac**k**up — full backup), `.spde`
(**S**elf**P**ost **d**omain **e**xport — domain export); unencrypted files
@@ -329,7 +351,13 @@ operator can act on it.
`-password-file`, never as an argument (the process list is readable by any
process in the container).
- Minimum password length matches the administrator password (12): the file
sits offline and can be attacked without a time limit.
sits offline and can be attacked without a time limit. The panel's forms and,
since `2.5.3`, the CLI hold an encrypting password to it; decrypting takes
any.
- The CLI's `-o FILE` is written beside its name and takes it only when
whole, so a failure - a wrong password to `-decrypt` among them, which is
checked on the first chunk before the output is touched - leaves the file
that was there (`2.5.3`).
---