base-trixie: findings of the reviews
test / test (push) Canceled after 0s

Security review (1c6e04b):
- The panel's database and its -wal/-shm were 0644 under a traversable
  /data, holding the panel users' password hashes and session token
  hashes. store.Open now makes them 0600 (SQLite gives the next -wal/-shm
  the database's mode: findCreateFileMode(), unixOpenSharedMemory());
  TestOpenKeepsTheDatabasePrivate.
- The entrypoint removes a symbolic link the panel user could leave where
  a /data directory, table, map, the queue or mail.log is expected, before
  its root steps chmod or create them (drop_links).
- The panel user is 10999:10999, outside the system range: in trixie
  useradd gave it group 998, the host's systemd-network on the test
  server; bookworm's 999 was the host's systemd-journal.
- security.md said the database was 0640; it was 0644.

Technical review (1c6e04b):
- mail_helpers.go: smtp-sink does add a Received: header, "(smtp-sink)"
  (smtp-sink.c:520-531); the "(Postfix)" filter leaves it out.
- CHANGELOG/roadmap: OpenDKIM's signing and From:-parsing code is
  unchanged, not all of it; the retry-policy bug predates 3.9 (any
  postconf warning); three entries moved from Fixed to Changed, as no
  released image had them.
- internal/app/sasl.go: db_dump's stdout alone is parsed.
- mynetworks comment: postinst:180, fset_all_changed.

Tests: on the server, make e2e exit 0 (712 s), go vet + go test 27
packages ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-15 20:05:01 +03:00
co-authored by Claude Opus 5.5
parent 1c6e04b7d3
commit a3050afb66
10 changed files with 164 additions and 31 deletions
+39 -16
View File
@@ -7,6 +7,20 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Security
- **The panel's database was readable by every user of the container.**
`selfpost.db` and its `-wal` and `-shm` files were 0644 under a
traversable `/data`; they hold the panel users' password hashes and the
session token hashes, and Postfix's smtpd - which faces the Internet -
and OpenDKIM run as other users there. The panel now makes them 0600 at
every start; SQLite gives the next `-wal` and `-shm` the database's mode.
Found by the security review of this release.
- **The entrypoint no longer follows a symbolic link the panel user could
leave under `/data`.** Several of its steps run as root and chmod,
create or regroup paths in a tree the panel writes; a link where a
directory or table is expected is now removed first. No escalation was
found - a compromised panel could only have turned it against the
container itself.
- **The image runs Debian trixie instead of bookworm** (base-trixie).
Bookworm left regular security support on 2026-07-12, and its Postfix
3.7.11 lacks upstream's 2026 security fixes, which carry no CVE ids and
@@ -14,7 +28,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
August batch (among them a BDAT memory exhaustion, a crash by DATA after
a BDAT error, and a policy and milter bypass after an end-of-data
reject), Debian's security support until 2028, OpenSSL 3.5 and libmilter
8.18.1. OpenDKIM's code is unchanged. The build stage is
8.18.1. OpenDKIM's signing and From:-parsing code is unchanged (Debian's
new patches touch the verifier's Authentication-Results and librbl).
The build stage is
`golang:1.26-trixie`.
### Changed
@@ -54,7 +70,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
the trixie image a package took it and the build failed, and on a host it
is often a system group of the host's own, which could then read the key
as well (on the test server's host it is `systemd-network`). 10998 is
outside that range. **On upgrade, give the private key in `certs/` to
outside that range. The panel user is 10999:10999 for the same reason
(it was 999:999, the host's `systemd-journal` group on the test server).
**On upgrade, give the private key in `certs/` to
the new group on the host** - `chgrp 10998` where the guide said
`chgrp 998`, in a certbot deploy hook too - or a full backup is refused
until then; Postfix reads its own copy and is not affected. The other
@@ -63,22 +81,27 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
- `opendkim-tools` is no longer installed: nothing in the image ran it.
`openssl` is installed by name, for the certificate reload.
- **Made ready for what trixie's programs do differently:** the log tailer
reads Postfix 3.10's refusal lines that carry `sasl_method=`,
`sasl_username=` and `sasl_sender=`, and cleanup's `removed (canceled)`
for a transaction that did not complete; the rotation loop refuses on
logrotate 3.22's "Ignoring" output, now with exit status 1 where 3.21
gave 0; Debian's chroot sync, which trixie's postfix runs at every
start, is turned off (`SYNC_CHROOT=` in `/etc/default/postfix`; nothing
runs chrooted), so `/etc/resolv.conf` and the like are not copied into
the queue under `/data`; a mail.log Postfix creates itself is `0640`, as
the entrypoint and logrotate create it.
### Fixed
- **The retry policy shown in the panel could fall back to Postfix's
compiled-in defaults:** the panel read `postconf`'s warnings together
with its values, and since Postfix 3.9 `postconf` warns about deprecated
parameters. It reads the values alone now and logs the warning.
- **logrotate 3.22 exits 1 when it skips a config it does not trust;**
3.21 exited 0. The rotation loop now refuses to run on that output
whatever the exit code, instead of retrying for ever.
- **The log tailer reads Postfix 3.10's new line shapes:** refusal lines
that carry `sasl_method=`, `sasl_username=` and `sasl_sender=`, and
cleanup's `removed (canceled)` for a transaction that did not complete.
- Debian's chroot sync no longer copies `/etc/resolv.conf` and the like
into the queue under `/data` at every start (`SYNC_CHROOT=` in
`/etc/default/postfix`; nothing runs chrooted). A Postfix-created
mail.log is `0640`, as the entrypoint and logrotate create it.
- **The retry policy shown in the panel fell back to Postfix's compiled-in
defaults whenever `postconf` warned:** the panel read `postconf`'s
standard error together with its values, and a warning there - an
unused parameter in main.cf, and since Postfix 3.9 a deprecated one -
was one value too many. The image's own main.cf sets none, so it took a
parameter an operator added. The panel reads the values alone now and
logs the warning. `db_dump`'s output, which the panel parses to list the
SASL accounts, is read the same way.
## [2.6.1] - 2026-10-15
+8 -4
View File
@@ -85,10 +85,14 @@ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
# it off (debian/configure-instance.in:22-24 and :44; debian/README.Debian:136-138).
RUN printf 'SYNC_CHROOT=\n' > /etc/default/postfix
# Unprivileged user for the panel process. Its ids are left to useradd: the
# entrypoint gives /data back to it by name on every start, so a number
# that differs from one image to the next changes nothing.
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
# Unprivileged user for the panel process, with ids of its own outside the
# system range for the same reason as selfpost's below: its files under
# /data show on the host by number, and a number from that range is a
# system group of the host's too (useradd gave the panel group 998 in the
# trixie image, systemd-network on the test server's host; bookworm's
# 999:999 was the host's systemd-journal group). The entrypoint gives /data
# back to it by name on every start, so files of the old ids follow.
RUN groupadd --system --gid 10999 panel && useradd --system --uid 10999 --gid panel --no-create-home --shell /usr/sbin/nologin panel
# Shared group bridging the unprivileged services: the panel generates
# per-domain DKIM keys, application SASL accounts (sasldb2) and the Postfix
+26 -1
View File
@@ -66,6 +66,31 @@ esac
# mode 0o700".)
chown panel:panel /data
chmod 755 /data
# What follows runs as root over a tree the panel user writes: /data is the
# panel's, and so is /data/postfix. A symbolic link the panel put where a
# directory or a file of its own is expected would lead root's mkdir, ": >",
# chmod and chgrp to whatever it names (chmod follows a link; so do a
# redirect and mkdir -p; chown -R and find do not descend into one given as
# their operand). None of these paths is ever a link in a working
# installation, so a link there is removed and the path made anew below.
# (Security review of base-trixie, 2026-10-15: no escalation found, as the
# TLS key copy and the read-only mounts stand, but nothing for root to
# follow either.)
drop_links() {
for p in "$@"; do
if [ -L "$p" ]; then
echo "entrypoint: removing the symbolic link $p" >&2
rm -f "$p"
fi
done
}
drop_links /data/opendkim /data/sasl /data/postfix /data/antispam /data/log \
/data/opendkim/keys /data/opendkim/KeyTable /data/opendkim/SigningTable \
/data/sasl/sasldb2 /data/postfix/queue \
/data/postfix/sender_login_maps /data/postfix/relay_domains \
/data/postfix/transport /data/postfix/relay_recipients /data/postfix/tls_policy \
/data/log/mail.log
# Restored backups or previously-created state may contain panel-owned files
# under /data; make sure they stay writable without disturbing anything that a
# later phase deliberately hands to another service. /data/log is exempt: it is
@@ -73,7 +98,7 @@ chmod 755 /data
# normalised on its own below, as are the trees shared through group selfpost.
# A file's owner is a number: an entry whose user or group is not the panel's
# by that number - unpacked by root, or written under another image whose
# useradd gave the panel other ids (bookworm's 999:999, until 2.7.0) - is
# image gave the panel other ids (999:999 until 2.7.0, 10999:10999 since) - is
# handed over whole.
find /data -mindepth 1 -maxdepth 1 ! -name log ! -name postfix ! -name opendkim ! -name sasl ! -name antispam \
\( ! -user panel -o ! -group panel \) -exec chown -R panel:panel {} +
+2 -1
View File
@@ -387,7 +387,8 @@ postconf -e \
# what the exceptions above and smtpd_forbid_bare_newline_exclusions (below)
# spare. The value is the one Debian's debconf default gives (postfix
# 3.10.13-0+deb13u1, debian/templates:124-126, "postfix/mynetworks", written
# by debian/postfix.postinst:249-262; debian/main.cf.in:28 has 127.0.0.0/8
# by debian/postfix.postinst:249-262 on a new main.cf, where :180,
# fset_all_changed, marks every debconf value to be written; main.cf.in:28 has 127.0.0.0/8
# alone), so on this image it changes nothing.
postconf -e \
"mynetworks=127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128" \
+3 -2
View File
@@ -825,8 +825,9 @@ attention to 3.10's reject logging (`sasl_method`, `sasl_username`,
cleanup's `canceled` line, 3.8's `postfix(1)` stderr logging under
supervisord; `dev/upstream` refetched from trixie; the cited-package table
and `checkCitedPackageVersions`; the e2e sink image; `opendkim-tools`
dropped from the image (used by nothing at run time). OpenDKIM's code is
the same in trixie, its citations stand. One stage, a MINOR release. Also
dropped from the image (used by nothing at run time). OpenDKIM's signing
and From:-parsing code is the same in trixie (its new Debian patches touch
the verifier and librbl), its citations stand. One stage, a MINOR release. Also
gained: `smtpd_hide_client_session` (3.10); the owner decided on
2026-10-15 to turn it on for 465 and 587 in the same release, so the
`Received:` header no longer shows the application's address.
+3 -3
View File
@@ -518,11 +518,11 @@ deferred item from the roadmap.
- **The filter lists are files readable by anyone on the host.**
`/data/antispam` is 0755 and its six files 0644, unlike the rest of `/data`:
the filter runs under another user in another container (rspamd as
`_rspamd`, uid 11333, in `rspamd/rspamd:3.11.1-3`; the panel is uid 999 -
measured on a live server, 2026-10-15). They hold sender
`_rspamd`, uid 11333, in `rspamd/rspamd:3.11.1-3`, measured on a live
server, 2026-10-15; the panel is uid 10999 since `2.7.0`). They hold sender
addresses, domains and networks - who the operator blocks and whom he lets
through - but no message data. The inbound journal itself is in the
database, 0640. Trigger to revisit: a shared host, where the sidecar should
database, which is 0600 since `2.7.0` (0644 before). Trigger to revisit: a shared host, where the sidecar should
instead be given the files through a group it shares with the panel.
- ~~**An application can learn which domains are senders of this server.**~~
Struck in `2.5.2` (audit 2026-10-12, stage 2c; L8 closed): the sender
+8 -2
View File
@@ -309,11 +309,17 @@ func runSaslpasswd2(args []string, stdin []byte) error {
// dumpSASLDB runs db_dump to export the sasldb2 as key/value hex pairs. The path
// is our own sasldb2 file (never user input) and is passed as a fixed argument
// with no shell (security.md).
//
// Only standard output is the dump: a message db_dump writes to standard
// error is not data and is left out of what is parsed (the same rule as
// postconf's values in internal/postfix, retry.go; review of base-trixie).
func dumpSASLDB(path string) ([]byte, error) {
cmd := exec.Command("db_dump", path)
out, err := cmd.CombinedOutput()
var errOut bytes.Buffer
cmd.Stderr = &errOut
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("db_dump: %w: %s", err, strings.TrimSpace(string(out)))
return nil, fmt.Errorf("db_dump: %w: %s", err, strings.TrimSpace(errOut.String()))
}
return out, nil
}
+43
View File
@@ -0,0 +1,43 @@
package store
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// The database and its WAL and shared-memory files are the panel's alone:
// a file that was 0644 is 0600 after Open, and the -wal and -shm files
// SQLite opens are 0600 too (keepPrivate).
func TestOpenKeepsTheDatabasePrivate(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("file modes are POSIX")
}
path := filepath.Join(t.TempDir(), "selfpost.db")
st, err := Open(path)
if err != nil {
t.Fatal(err)
}
st.Close()
if err := os.Chmod(path, 0o644); err != nil {
t.Fatal(err)
}
st, err = Open(path)
if err != nil {
t.Fatal(err)
}
defer st.Close()
if _, err := st.db.Exec(`PRAGMA user_version`); err != nil {
t.Fatal(err)
}
for _, p := range []string{path, path + "-wal", path + "-shm"} {
fi, err := os.Stat(p)
if err != nil {
t.Fatalf("%s: %v", filepath.Base(p), err)
}
if m := fi.Mode().Perm(); m != 0o600 {
t.Errorf("%s mode %o, want 600", filepath.Base(p), m)
}
}
}
+28
View File
@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"io/fs"
"os"
"sort"
_ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo), keeps the static build
@@ -63,9 +64,36 @@ func Open(path string) (*Store, error) {
db.Close()
return nil, err
}
if err := keepPrivate(path); err != nil {
db.Close()
return nil, err
}
return s, nil
}
// keepPrivate makes the database file and its WAL and shared-memory files
// the panel's alone (0600). They hold the panel users' password hashes and
// the session token hashes, and /data is traversable by every user of the
// container (entrypoint.sh) - Postfix and OpenDKIM among them. SQLite
// creates a file with 0644 less the umask, and the panel sets no umask; the
// -wal and -shm files take the database file's mode when they are created
// (SQLite 3.53.4 as modernc.org/sqlite v1.60.1 builds it,
// lib/sqlite_linux_386.go: findCreateFileMode(), "WAL and journal files are
// created using the same permissions as the associated database file";
// unixOpenSharedMemory(), robust_open(... st_mode&0777) of the database's
// fstat). So the database file is chmodded and the two files beside it,
// where they exist already; the next ones follow its mode. Found by the
// security review of base-trixie (2026-10-15): the file was 0644 on the
// test server.
func keepPrivate(path string) error {
for _, p := range []string{path, path + "-wal", path + "-shm"} {
if err := os.Chmod(p, 0o600); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("database mode: %w", err)
}
}
return nil
}
// Close closes the underlying database.
func (s *Store) Close() error {
return s.db.Close()
+4 -2
View File
@@ -78,8 +78,10 @@ func verifyDKIM(raw []byte, domain string) error {
// postfixReceived returns the Received: headers of raw that a Postfix smtpd
// wrote - "(Postfix)" after the host name, the only form it writes (Postfix
// 3.10.13, src/smtpd/smtpd.c:3627-3645, "by %s (%s)" with var_mail_name).
// In the stand only SelfPost's Postfix writes one: the sink is smtp-sink,
// which adds none, and the tests compose their messages without any.
// In the stand only SelfPost's Postfix writes one: the tests compose their
// messages without any, and the sink, smtp-sink, adds its own with "by
// <host> (smtp-sink)" (src/smtpstone/smtp-sink.c:520-531,
// mail_file_finish_header()), which the "(Postfix)" filter leaves out.
func postfixReceived(raw []byte) ([]string, error) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {