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>
71 lines
2.2 KiB
Go
71 lines
2.2 KiB
Go
package postfix
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// DMARCMapsConfig is the allow-list material for DMARC aggregate ingest.
|
|
type DMARCMapsConfig struct {
|
|
Recipients []string
|
|
Domains []string
|
|
}
|
|
|
|
func (p *Postfix) dmarcMapPaths() (recipients, transport, relayDomains string) {
|
|
dir := filepath.Dir(p.senderLoginMapsPath)
|
|
return filepath.Join(dir, "dmarc_recipients"),
|
|
filepath.Join(dir, "dmarc_transport"),
|
|
filepath.Join(dir, "dmarc_relay_domains")
|
|
}
|
|
|
|
// RebuildDMARCMaps regenerates DMARC ingest lookup tables. Empty config clears
|
|
// the files so port-25 ingest is off even if the listener stays up for inbound
|
|
// relay.
|
|
func (p *Postfix) RebuildDMARCMaps(cfg DMARCMapsConfig) error {
|
|
recip, transport, relay, err := renderDMARCMaps(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rc, tr, rd := p.dmarcMapPaths()
|
|
changed, err := writeChanged(mapFile{rc, recip}, mapFile{tr, transport}, mapFile{rd, relay})
|
|
if err != nil || !changed {
|
|
return err
|
|
}
|
|
return p.reload()
|
|
}
|
|
|
|
// Postfix 3.10.13, man/man5/access.5: "OK" accepts the address the key
|
|
// matches; man/man5/transport.5: "user@domain transport:nexthop", and "a
|
|
// non-null transport field with a null nexthop field resets the nexthop
|
|
// information to the recipient domain"; relay_domains as in renderInboundMaps.
|
|
func renderDMARCMaps(cfg DMARCMapsConfig) (recipients, transport, relayDomains []byte, err error) {
|
|
recipients = []byte{}
|
|
transport = []byte{}
|
|
relayDomains = []byte{}
|
|
if len(cfg.Recipients) == 0 {
|
|
return recipients, transport, relayDomains, nil
|
|
}
|
|
addrs := append([]string(nil), cfg.Recipients...)
|
|
sort.Strings(addrs)
|
|
var recipB, transportB strings.Builder
|
|
for _, addr := range addrs {
|
|
if err := assertMapToken(addr, "recipient"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
fmt.Fprintf(&recipB, "%s OK\n", addr)
|
|
fmt.Fprintf(&transportB, "%s dmarc-ingest:\n", addr)
|
|
}
|
|
domains := append([]string(nil), cfg.Domains...)
|
|
sort.Strings(domains)
|
|
var relayB strings.Builder
|
|
for _, d := range domains {
|
|
if err := assertMapToken(d, "domain"); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
fmt.Fprintf(&relayB, "%s OK\n", d)
|
|
}
|
|
return []byte(recipB.String()), []byte(transportB.String()), []byte(relayB.String()), nil
|
|
}
|