inbound journal: one row per recipient, and every row counts to the cap (audit M3)
Security audit 2026-10-17, finding M3, stage 1. inboundSession.RcptTo appended every RCPT (the outbound session de-duplicates), and the end of the message inserted a row per recipient one statement at a time inside Postfix's callback; the cut to 10 000 rows every 500 counted only the log tailer's rows. Postfix accepts a repeated recipient up to smtpd_recipient_limit (1 000) and sends each to the milter, so one message was a thousand rows, uncut until the six-hourly prune. Now: the inbound milter folds a repeated recipient (case-insensitive, as cleanup's been_here() with BH_FLAG_FOLD does, and as session.RcptTo does); a message's rows go in one transaction (store.InsertInboundSpamMessage, which replaces InsertInboundSpam in milter.InboundStore); the count and the cut moved from the tailer into the store (countInboundWrites, an atomic counter on Store) so the milter's rows, the tailer's and the tailer's start-up re-reads all count. store.InboundSpamLogMaxRows is the one cap. Tests: TestInboundRecipientRepeatedIsOneRow (1 000 RCPT in two cases, one row); TestInboundJournalIsCutWhicheverWriterWrites (a small cap: a message's rows and refusals together cross the mark, the table is cut); the tailer's own counting test went with its counter. go test ./internal/store ./internal/milter ./internal/logtail (Windows-only Follow failures aside). Also gofmt of a test line from the M2 commit. Docs: security.md § Mail path, architecture.md; CHANGELOG; plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ import (
|
||||
// and the list of inbound domains, since port 25 also takes mail that is not
|
||||
// the relay's. *store.Store satisfies it; tests substitute a fake.
|
||||
type InboundStore interface {
|
||||
InsertInboundSpam(e store.InboundSpamEntry) error
|
||||
InsertInboundSpamMessage(e store.InboundSpamEntry, rcpts []store.InboundRecipient) error
|
||||
IsInboundDomain(name string) (bool, error)
|
||||
ForgetInboundQueueID(queueID string) error
|
||||
}
|
||||
@@ -83,8 +83,19 @@ func (s *inboundSession) MailFrom(from string, m *milter.Modifier) (milter.Respo
|
||||
// RcptTo keeps the recipient as Postfix logs it, so that the delivery lines
|
||||
// that carry the upstream's answer find the row (recipientAsLogged): a quoted
|
||||
// local part that needs no quotes is written without them.
|
||||
//
|
||||
// A recipient given twice - in any case - is one row, as on the outbound side
|
||||
// (session.RcptTo): Postfix accepts a repeated RCPT up to
|
||||
// smtpd_recipient_limit and sends the milter each, and one message used to be
|
||||
// a thousand rows (security audit 2026-10-17, M3).
|
||||
func (s *inboundSession) RcptTo(rcpt string, m *milter.Modifier) (milter.Response, error) {
|
||||
s.rcpts = append(s.rcpts, recipientAsLogged(rcpt))
|
||||
to := recipientAsLogged(rcpt)
|
||||
for _, have := range s.rcpts {
|
||||
if strings.EqualFold(have, to) {
|
||||
return milter.RespContinue, nil
|
||||
}
|
||||
}
|
||||
s.rcpts = append(s.rcpts, to)
|
||||
return milter.RespContinue, nil
|
||||
}
|
||||
|
||||
@@ -215,6 +226,7 @@ func (s *inboundSession) record(queueID string) {
|
||||
log.Printf("inbound-journal: detach older rows of %s: %v", queueID, err)
|
||||
}
|
||||
}
|
||||
var rows []store.InboundRecipient
|
||||
for _, to := range s.rcpts {
|
||||
domain := domainOf(to)
|
||||
ok, err := s.rec.IsInboundDomain(domain)
|
||||
@@ -222,14 +234,13 @@ func (s *inboundSession) record(queueID string) {
|
||||
log.Printf("inbound-journal: look up %s: %v", domain, err)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
e.InboundDomain, e.To = domain, to
|
||||
if err := s.rec.InsertInboundSpam(e); err != nil {
|
||||
log.Printf("inbound-journal: record %s -> %s: %v", s.from, to, err)
|
||||
if ok {
|
||||
rows = append(rows, store.InboundRecipient{Domain: domain, To: to})
|
||||
}
|
||||
}
|
||||
if err := s.rec.InsertInboundSpamMessage(e, rows); err != nil {
|
||||
log.Printf("inbound-journal: record %s -> %d recipients: %v", s.from, len(rows), err)
|
||||
}
|
||||
}
|
||||
|
||||
// What the journal keeps of a verdict: enough to see why, on one line.
|
||||
|
||||
@@ -20,11 +20,14 @@ type fakeInbound struct {
|
||||
forgot []string
|
||||
}
|
||||
|
||||
func (f *fakeInbound) InsertInboundSpam(e store.InboundSpamEntry) error {
|
||||
func (f *fakeInbound) InsertInboundSpamMessage(e store.InboundSpamEntry, rcpts []store.InboundRecipient) error {
|
||||
if f.insertErr != nil {
|
||||
return f.insertErr
|
||||
}
|
||||
f.rows = append(f.rows, e)
|
||||
for _, r := range rcpts {
|
||||
e.InboundDomain, e.To = r.Domain, r.To
|
||||
f.rows = append(f.rows, e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -400,3 +403,23 @@ func TestInboundRowsMeetTheirDeliveryLines(t *testing.T) {
|
||||
t.Errorf("with no queue id: rows %+v, detached %q", rec.rows, rec.forgot)
|
||||
}
|
||||
}
|
||||
|
||||
// A recipient given again - in any case - is one row: Postfix accepts a
|
||||
// repeated RCPT up to smtpd_recipient_limit (1 000) and sends each, and one
|
||||
// message used to be a thousand rows (security audit 2026-10-17, M3).
|
||||
func TestInboundRecipientRepeatedIsOneRow(t *testing.T) {
|
||||
rec := &fakeInbound{domains: map[string]bool{"in.example.com": true}}
|
||||
s := inboundSessionFor(t, rec, false)
|
||||
rcpts := make([]string, 0, 1000)
|
||||
for i := 0; i < 1000; i++ {
|
||||
if i%2 == 0 {
|
||||
rcpts = append(rcpts, "listed@in.example.com")
|
||||
} else {
|
||||
rcpts = append(rcpts, "LISTED@in.example.com")
|
||||
}
|
||||
}
|
||||
message(t, s, "sender@example.net", rcpts, nil)
|
||||
if len(rec.rows) != 1 || rec.rows[0].To != "listed@in.example.com" {
|
||||
t.Errorf("rows = %d (%+v), want one, spelt as first given", len(rec.rows), rec.rows)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user