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:
mix
2026-10-17 21:44:28 +03:00
co-authored by Claude Opus 5.5
parent d254a9952d
commit c50314fb4c
11 changed files with 216 additions and 53 deletions
+57
View File
@@ -1,6 +1,7 @@
package store
import (
"fmt"
"strings"
"testing"
"time"
@@ -495,3 +496,59 @@ func TestInboundUpstreamHeldAndReusedQueueID(t *testing.T) {
t.Errorf("ForgetInboundQueueID does not use idx_inbound_spam_log_queue_id:\n%s", plan)
}
}
// The journal is cut to its cap every so many rows, whichever writer wrote
// them: the journal milter's rows of accepted mail count as the tailer's
// refusals do (security audit 2026-10-17, M3). One message's rows are one
// call. Here the cap is 5 rows and the cut every 4.
func TestInboundJournalIsCutWhicheverWriterWrites(t *testing.T) {
st := openTestStore(t)
rowsBefore, everyBefore := inboundCapRows, inboundCapEvery
inboundCapRows, inboundCapEvery = 5, 4
t.Cleanup(func() { inboundCapRows, inboundCapEvery = rowsBefore, everyBefore })
if _, err := st.AddInboundDomain("in.example.com"); err != nil {
t.Fatal(err)
}
count := func() int {
var n int
if err := st.db.QueryRow("SELECT COUNT(*) FROM inbound_spam_log").Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
accepted := InboundSpamEntry{ClientIP: "203.0.113.7", From: "a@example.net", Decision: SpamDecisionAccept, Engine: SpamEngineSelfPost}
var rcpts []InboundRecipient
for i := 0; i < 3; i++ {
rcpts = append(rcpts, InboundRecipient{Domain: "in.example.com", To: fmt.Sprintf("r%d@in.example.com", i)})
}
// Three rows of one message, then two refusals: the fourth row crosses
// the mark, and the journal is cut to five.
if err := st.InsertInboundSpamMessage(accepted, rcpts); err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err := st.InsertInboundSpam(InboundSpamEntry{InboundDomain: "in.example.com", ClientIP: "203.0.113.9", To: "x@in.example.com",
Decision: SpamDecisionReject, Engine: SpamEnginePostfix}); err != nil {
t.Fatal(err)
}
}
if n := count(); n != 5 {
t.Fatalf("after 5 rows, %d (no cut yet past the cap)", n)
}
// A message of six more crosses the mark again: cut to five.
rcpts = nil
for i := 0; i < 6; i++ {
rcpts = append(rcpts, InboundRecipient{Domain: "in.example.com", To: fmt.Sprintf("m%d@in.example.com", i)})
}
if err := st.InsertInboundSpamMessage(accepted, rcpts); err != nil {
t.Fatal(err)
}
if n := count(); n != 5 {
t.Errorf("after a message of six over the mark, %d rows, want the cap of 5", n)
}
// The message's rows carry the domain by id, like the tailer's.
var missing int
if err := st.db.QueryRow("SELECT COUNT(*) FROM inbound_spam_log WHERE inbound_domain_id IS NULL").Scan(&missing); err != nil || missing != 0 {
t.Errorf("%d rows without a domain id (%v)", missing, err)
}
}