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:
@@ -54,6 +54,8 @@ type Module struct {
|
||||
// ignored (links.go, sendReset).
|
||||
resetDayLimiter *rateLimiter
|
||||
resetNoticeLimiter *rateLimiter
|
||||
// recheckLimiter counts the DNS re-checks a user forces (AllowDNSRecheck).
|
||||
recheckLimiter *rateLimiter
|
||||
|
||||
proxyWatch proxyWatch
|
||||
refusals *refusalLog
|
||||
@@ -98,6 +100,7 @@ func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Mo
|
||||
// silence for the rest of the day (security audit 2026-10-17, A3).
|
||||
resetDayLimiter: newRateLimiter(resetsPerDay, 24*time.Hour),
|
||||
resetNoticeLimiter: newRateLimiter(1, 24*time.Hour),
|
||||
recheckLimiter: newRateLimiter(20, 15*time.Minute),
|
||||
trustedProxies: cfg.TrustedProxyCIDRs,
|
||||
}
|
||||
m.setupLimiter.startSweeper()
|
||||
@@ -112,6 +115,7 @@ func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Mo
|
||||
m.mailLimiter.startSweeper()
|
||||
m.resetDayLimiter.startSweeper()
|
||||
m.resetNoticeLimiter.startSweeper()
|
||||
m.recheckLimiter.startSweeper()
|
||||
// Made now rather than at the first unknown name, which would otherwise
|
||||
// pay for two bcrypt runs (review of audit stage 4).
|
||||
go noSuchUserHash()
|
||||
@@ -131,6 +135,14 @@ func (m *Module) AllowPasswordChange(userID int64) bool {
|
||||
return m.passwordLimiter.Allow("user:" + strconv.FormatInt(userID, 10))
|
||||
}
|
||||
|
||||
// AllowDNSRecheck is whether a user may make the DNS checks again, past the
|
||||
// five-minute cache: each re-check is up to fourteen resolver queries, and a
|
||||
// domain user could press it without end (security audit 2026-10-17, M9).
|
||||
// Twenty in fifteen minutes per user.
|
||||
func (m *Module) AllowDNSRecheck(userID int64) bool {
|
||||
return m.recheckLimiter.Allow("user:" + strconv.FormatInt(userID, 10))
|
||||
}
|
||||
|
||||
// PasswordCheckPassed gives back the attempt AllowPasswordChange took for a
|
||||
// check that found the right password: only wrong guesses count, so an
|
||||
// administrator who adds ten users in a quarter of an hour is not locked out
|
||||
|
||||
@@ -114,3 +114,21 @@ func TestProxyWatchAndRefusalsAreLogged(t *testing.T) {
|
||||
t.Errorf("refusals logged %d times (want 5), or the name unescaped:\n%s", n, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A user forces the DNS checks twenty times in fifteen minutes, then sees
|
||||
// the cached result (security audit 2026-10-17, M9); another user is not
|
||||
// affected.
|
||||
func TestDNSRechecksArePerUser(t *testing.T) {
|
||||
m := testModule(t, false)
|
||||
for i := 0; i < 20; i++ {
|
||||
if !m.AllowDNSRecheck(1) {
|
||||
t.Fatalf("re-check %d refused", i+1)
|
||||
}
|
||||
}
|
||||
if m.AllowDNSRecheck(1) {
|
||||
t.Error("the 21st re-check was allowed")
|
||||
}
|
||||
if !m.AllowDNSRecheck(2) {
|
||||
t.Error("another user's first re-check was refused")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user