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:
@@ -305,7 +305,7 @@ func TestInboundHeloCannotWriteTheRow(t *testing.T) {
|
||||
func TestInboundRecipientCannotWriteTheRow(t *testing.T) {
|
||||
forged := `; from=<ceo@example.net> to=<board@in.example.com> proto=ESMTP helo=<x>; `
|
||||
for name, line := range map[string]string{
|
||||
"recipient unknown": head + `postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 550 5.1.1 <` + forged + `@in.example.com>: Recipient address rejected: User unknown in relay recipient table; from=<probe@example.net> to=<"` + forged + `"@in.example.com> proto=ESMTP helo=<audit.example>`,
|
||||
"recipient unknown": head + `postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 550 5.1.1 <` + forged + `@in.example.com>: Recipient address rejected: User unknown in relay recipient table; from=<probe@example.net> to=<"` + forged + `"@in.example.com> proto=ESMTP helo=<audit.example>`,
|
||||
"forged sender also in MAIL FROM": head + `postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 550 5.1.1 <` + forged + `@in.example.com>: Recipient address rejected: User unknown in relay recipient table; from=<"x; from=<ceo@example.net"@a.example> to=<"` + forged + `"@in.example.com> proto=ESMTP helo=<audit.example>`,
|
||||
} {
|
||||
j, st := inboundFixture()
|
||||
@@ -362,18 +362,6 @@ func TestInboundStaleArrivalDoesNotDropALiveID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Between the six-hourly prunes the tailer has the journal cut to its cap
|
||||
// itself, every so many rows: refusals come at the rate strangers send them.
|
||||
func TestInboundCutsTheJournalAsItWrites(t *testing.T) {
|
||||
j, st := inboundFixture()
|
||||
for i := 0; i < inboundPruneEvery*2+10; i++ {
|
||||
j.handle(unknownRcp, tailNow, false)
|
||||
}
|
||||
if len(st.pruned) != 2 || st.pruned[0] != inboundSpamLogMaxRows {
|
||||
t.Errorf("cuts = %v, want two, to %d rows", st.pruned, inboundSpamLogMaxRows)
|
||||
}
|
||||
}
|
||||
|
||||
// The queue ids waiting for a verdict are bounded.
|
||||
func TestInboundQueueIDsAreBounded(t *testing.T) {
|
||||
j, _ := inboundFixture()
|
||||
|
||||
Reference in New Issue
Block a user