milter: the sender check keeps two From: and two Sender: values (audit M5)

Security audit 2026-10-17, finding M5 (low), stage 0. senderSession.Header
appended every From:/Sender: value; Postfix bounds one header at 60 000
bytes but not their number, and the signer's 1 MiB cap runs after the
sender check, so an application could make it hold megabytes per
connection (the reader's test: a hundred lines, 6 MB). judge needs only
none / one / more, so two of each are kept; the refusal text joins what is
kept, as before.

Tests: "a hundred From: lines" in the judgement table (refused, more than
one From: line); TestSenderKeepsTwoFromAndSenderLines. go test
./internal/milter.

Docs: security.md (the sentence "a few headers each"); CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-17 21:33:53 +03:00
co-authored by Claude Opus 5.5
parent 683fdafc6d
commit da2e8b2ffe
5 changed files with 40 additions and 5 deletions
+9 -2
View File
@@ -309,11 +309,18 @@ func (s *senderSession) RcptTo(rcpt string, m *milter.Modifier) (milter.Response
func (s *senderSession) Header(name, value string, m *milter.Modifier) (milter.Response, error) {
// The name is trimmed: the obsolete syntax allows blanks before the colon.
// Two of each are kept: the judgement needs only none, one or more, and
// Postfix bounds a header's length but not their number (audit
// 2026-10-17, M5: a hundred 60 000-byte From: lines were 6 MB held).
switch textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(name)) {
case "From":
s.froms = append(s.froms, value)
if len(s.froms) < 2 {
s.froms = append(s.froms, value)
}
case "Sender":
s.senders = append(s.senders, value)
if len(s.senders) < 2 {
s.senders = append(s.senders, value)
}
case "Subject":
if s.subject == "" {
s.subject = mailhdr.DecodeSubject(value)