postfix: no reload for a map that did not change; DNS re-checks limited (audit W6/M9)
Security audit 2026-10-17, findings W6/M9 (low), stage 4. Every application, upstream, recipient or report-address save reloaded Postfix through supervisord, and POST .../dns-recheck bypassed the five-minute cache for up to ~14 resolver queries - both at whatever rate an authenticated user pressed. - writeChanged: each generated map is written only when its content differs from the file on disk, and Postfix is reloaded only when one was (sender map, inbound maps, DMARC maps). Health's Reload configuration ends with an explicit reload (app.Service.ReloadPostfix, SenderMaps gains Reload), so it still re-reads whatever is on disk. No debounce: with unchanged saves gone, a burst is real changes Postfix must see. - auth.AllowDNSRecheck: twenty forced re-checks in 15 minutes per user; past that the outbound, inbound and settings re-check show the cached result without the "re-checked" flash, and the log says why. Tests: TestARebuildThatChangesNothingDoesNotReload, TestDNSRechecksArePerUser; the fakes gain Reload. Docs: guide § Overview and Health; CHANGELOG; plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,13 +29,8 @@ func (p *Postfix) RebuildDMARCMaps(cfg DMARCMapsConfig) error {
|
||||
return err
|
||||
}
|
||||
rc, tr, rd := p.dmarcMapPaths()
|
||||
if err := writeFileAtomic(rc, recip, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(tr, transport, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(rd, relay, 0o640); err != nil {
|
||||
changed, err := writeChanged(mapFile{rc, recip}, mapFile{tr, transport}, mapFile{rd, relay})
|
||||
if err != nil || !changed {
|
||||
return err
|
||||
}
|
||||
return p.reload()
|
||||
|
||||
@@ -40,16 +40,8 @@ func (p *Postfix) RebuildInboundMaps(routes []InboundRoute) error {
|
||||
return err
|
||||
}
|
||||
rd, tr, rc, tl := p.inboundMapPaths()
|
||||
if err := writeFileAtomic(rd, relay, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(tr, transport, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(rc, recipients, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(tl, tlsPolicy, 0o640); err != nil {
|
||||
changed, err := writeChanged(mapFile{rd, relay}, mapFile{tr, transport}, mapFile{rc, recipients}, mapFile{tl, tlsPolicy})
|
||||
if err != nil || !changed {
|
||||
return err
|
||||
}
|
||||
return p.reload()
|
||||
|
||||
@@ -69,7 +69,8 @@ func (p *Postfix) RebuildSenderLoginMaps(bindings []Binding) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(p.senderLoginMapsPath, content, 0o640); err != nil {
|
||||
changed, err := writeChanged(mapFile{p.senderLoginMapsPath, content})
|
||||
if err != nil || !changed {
|
||||
return err
|
||||
}
|
||||
return p.reload()
|
||||
|
||||
@@ -132,3 +132,33 @@ func TestRebuildRejectsUnsafeWithoutWriting(t *testing.T) {
|
||||
t.Errorf("reload called %d times, want 1 (no reload on failure)", *reloads)
|
||||
}
|
||||
}
|
||||
|
||||
// A rebuild that changes no file writes nothing and asks no reload; a change
|
||||
// in any one file reloads once (security audit 2026-10-17, W6).
|
||||
func TestARebuildThatChangesNothingDoesNotReload(t *testing.T) {
|
||||
p, reloads := newTestPostfix(t)
|
||||
b := []Binding{{"@example.com", "app1"}}
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := p.RebuildSenderLoginMaps(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if *reloads != 1 {
|
||||
t.Errorf("three identical sender-map rebuilds reloaded %d times, want 1", *reloads)
|
||||
}
|
||||
routes := []InboundRoute{{Domain: "in.example.com", Host: "mx.example.net", Port: 25, TLSMode: "may", RecipientMode: "any"}}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := p.RebuildInboundMaps(routes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if *reloads != 2 {
|
||||
t.Errorf("two identical inbound rebuilds after it: %d reloads in all, want 2", *reloads)
|
||||
}
|
||||
if err := p.RebuildSenderLoginMaps(append(b, Binding{"@other.example", "app2"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *reloads != 3 {
|
||||
t.Errorf("a changed map: %d reloads in all, want 3", *reloads)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package postfix
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/atomicfile"
|
||||
@@ -12,3 +13,28 @@ import (
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
return atomicfile.Write(path, data, perm)
|
||||
}
|
||||
|
||||
// mapFile is one generated table and what it should hold.
|
||||
type mapFile struct {
|
||||
path string
|
||||
data []byte
|
||||
}
|
||||
|
||||
// writeChanged writes each file whose content differs from what is on disk
|
||||
// and says whether any did. A save that changes nothing Postfix reads - a
|
||||
// domain user saving a form as it was, or changing a field no map holds -
|
||||
// then writes nothing and asks no reload: every save used to reload Postfix,
|
||||
// at whatever rate a user cared to press (security audit 2026-10-17, W6).
|
||||
func writeChanged(files ...mapFile) (bool, error) {
|
||||
changed := false
|
||||
for _, f := range files {
|
||||
if old, err := os.ReadFile(f.path); err == nil && bytes.Equal(old, f.data) {
|
||||
continue
|
||||
}
|
||||
if err := writeFileAtomic(f.path, f.data, 0o640); err != nil {
|
||||
return changed, err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user