image: the TLS key's group is the panel's alone, 10997 (audit I1)
Security audit 2026-10-17, finding I1, stage 2; the help mockup is ff3a118.
The guide had operators give the TLS private key to group 10998 so the
panel can archive it, and the Dockerfile puts postfix in that group (for
sasldb2 and the maps): `docker exec -u postfix head privkey.pem` printed
the live key. postfix-config.sh copies the key to a root-only directory
precisely so that a compromised Postfix daemon cannot read it - the mounted
original undid that.
Now a group backupkey (gid 10997, outside the system range like 10998)
whose one member is the panel; the guide (Apache/certbot, nginx, Taking a
full backup, the backup table), Help, and the backup's refusal name 10997
and say why; postfix-config.sh, extract-cert.sh and the Dockerfile's
comments follow. A key still given to 10998 keeps working for the backup.
CHANGELOG has an Upgrading note.
e2e: the stage gives its key to 10997 at 0640 when the runner is root
(stageKeyMode; 0644 otherwise), and testFullBackupRestore, after the
refusal that now names chgrp 10997, checks that the postfix uid cannot read
/etc/postfix/tls/privkey.pem and the panel can.
Docs: security.md § Output and process (the groups); guide; CHANGELOG;
plan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,7 @@ import (
|
||||
// record that was published before the backup.
|
||||
func testFullBackupRestore(t *testing.T, sc *scenario) {
|
||||
// A private key the panel may not read - the state a server is in until
|
||||
// its operator gives the key to group 10998. The panel does not make an
|
||||
// its operator gives the key to group 10997. The panel does not make an
|
||||
// archive without it: it refuses, names the file and says what to do.
|
||||
keyPath := filepath.Join(h.stageDir, "certs", "privkey.pem")
|
||||
if err := os.Chmod(keyPath, 0o600); err != nil {
|
||||
@@ -46,12 +46,23 @@ func testFullBackupRestore(t *testing.T, sc *scenario) {
|
||||
if err != nil {
|
||||
t.Fatalf("full backup with an unreadable key: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 500 || !strings.Contains(body, "not allowed to read certs/privkey.pem") || !strings.Contains(body, "chgrp 10998") {
|
||||
if resp.StatusCode != 500 || !strings.Contains(body, "not allowed to read certs/privkey.pem") || !strings.Contains(body, "chgrp 10997") {
|
||||
t.Fatalf("full backup with an unreadable key: status %d, want a refusal that names the file and the way out:\n%.800s", resp.StatusCode, body)
|
||||
}
|
||||
if err := os.Chmod(keyPath, 0o644); err != nil {
|
||||
if err := stageKeyMode(keyPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Given to group 10997 the key is the panel's to read and no Postfix
|
||||
// daemon's: postfix is in selfpost, not in backupkey (security audit
|
||||
// 2026-10-17, I1). Only where the runner could give it that group.
|
||||
if keyGroupStaged {
|
||||
if out, err := h.compose("exec", "-T", "-u", "postfix", "selfpost", "head", "-c", "1", "/etc/postfix/tls/privkey.pem"); err == nil {
|
||||
t.Errorf("the postfix uid reads the TLS key given to group %d:\n%s", keyGroup, out)
|
||||
}
|
||||
if out, err := h.compose("exec", "-T", "-u", "panel", "selfpost", "head", "-c", "1", "/etc/postfix/tls/privkey.pem"); err != nil {
|
||||
t.Errorf("the panel cannot read the TLS key given to group %d: %v\n%s", keyGroup, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// A map under data/postfix by a name the entrypoint does not list - the
|
||||
// DMARC maps were such, with DMARC on (review of audit stage 3, H1). The
|
||||
|
||||
+24
-5
@@ -116,13 +116,32 @@ func writeSelfSignedCert(certPath, keyPath string) error {
|
||||
return err
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
||||
// Readable by everyone, which a real key must never be. On a server the
|
||||
// operator gives the key to the container's selfpost group (10998) so the
|
||||
// panel can put it into a full backup; the runner of this suite is not
|
||||
// always root and cannot hand a file to a group it is not in. The key is
|
||||
// As the guide has the operator do: group 10997, the panel's alone, and
|
||||
// 0640 - the panel can back it up and no Postfix daemon can read it
|
||||
// (security audit 2026-10-17, I1). A runner that is not root cannot hand
|
||||
// a file to a group it is not in; then the key is readable by everyone,
|
||||
// which a real key must never be, and keyGroupStaged says so. The key is
|
||||
// thrown away with the stage directory.
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o644); err != nil {
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return stageKeyMode(keyPath)
|
||||
}
|
||||
|
||||
// keyGroup is the container's group for the TLS key (build/Dockerfile).
|
||||
const keyGroup = 10997
|
||||
|
||||
// keyGroupStaged is whether the stage's key went to keyGroup at 0640 (the
|
||||
// runner is root) rather than 0644.
|
||||
var keyGroupStaged bool
|
||||
|
||||
// stageKeyMode gives a key the group and mode the guide gives a real one, or
|
||||
// 0644 when the runner may not.
|
||||
func stageKeyMode(keyPath string) error {
|
||||
if err := os.Chown(keyPath, -1, keyGroup); err == nil {
|
||||
keyGroupStaged = true
|
||||
return os.Chmod(keyPath, 0o640)
|
||||
}
|
||||
keyGroupStaged = false
|
||||
return os.Chmod(keyPath, 0o644)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user