milter: the level-2 domain limit is the login's domain's (audit M1)

Security audit 2026-10-17, finding M1, stage 1. overLimit took
domainOf(MAIL FROM) whatever the login, and MailFrom ran it with an empty
login: with smtpd_delay_reject at its default Postfix runs no sender
restriction at MAIL and sends the milter's MAIL event at once, so a
stranger on 465/587 with no AUTH, or another tenant's application, could
name a victim domain in MAIL FROM and take its slots for the 10-minute
in-flight TTL, get the victim's application answered 451, and have a limit
row (and the "a rate limit is refusing mail" notification) written under
the victim's domain.

Now overLimit returns at once without a login and takes the domain from
ApplicationByLogin (loginDomain, fail-open like the rest); recordRejected's
Domain is the login's too. The send log already counts a domain's messages
by the login's domain (store.CountMessages, loginDomainID), so the limit
and the count now agree. The null-sender special case is gone: every
message is held to its login's domain.

Tests: TestRateLimitIsNotReservedByAnotherSendersMailFrom (a stranger with
no login; another tenant's application) - fails on the old code at the
second MAIL FROM; the domain-limit tests sign in as app1 at example.com.
e2e domain_limit_not_reserved_by_a_stranger: a domain with a limit of one,
three unauthenticated MAIL FROM naming it on 465, then its own message
passes and its second is refused 451.

Docs: security.md § Mail path; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-17 21:41:06 +03:00
co-authored by Claude Opus 5.5
parent 2c8e981f89
commit d254a9952d
7 changed files with 206 additions and 10 deletions
+31 -9
View File
@@ -1,7 +1,9 @@
package milter
import (
"errors"
"log"
"strings"
"time"
"github.com/mixeme/selfpost/internal/store"
@@ -38,15 +40,19 @@ func (s *session) overLimit() bool {
}
}
domain := domainOf(s.from)
if domain == "" && s.login != "" {
// MAIL FROM:<> has no domain to read; the login has one. A null
// sender is held to its application's domain like any other message
// of it (review of audit stage 2).
if a, err := s.rec.ApplicationByLogin(s.login); err == nil {
domain = a.DomainName
}
// The domain is the login's, never the envelope's. MAIL FROM reaches
// this milter before Postfix has held it to the login - with
// smtpd_delay_reject at its default no sender restriction runs at MAIL
// (Postfix 3.10.13, src/smtpd/smtpd.c, mail_cmd()) - so a domain read
// from it let a stranger on 465/587, with no login at all, or another
// tenant's application reserve a victim domain's slots (security audit
// 2026-10-17, M1). A session without a login can deliver nothing on
// those ports and reserves nothing; the send log counts a domain's
// messages by the login's domain too (store.CountMessages).
if s.login == "" {
return false
}
domain := s.loginDomain()
if domain == "" {
return false
}
@@ -99,7 +105,7 @@ func (s *session) releaseReservations() {
// and never affects the response.
func (s *session) recordRejected(reason string) {
err := s.rec.InsertRejected(store.SendLogEntry{
Domain: domainOf(s.from),
Domain: s.loginDomain(),
AppLogin: s.login,
From: s.from,
Reason: reason,
@@ -109,3 +115,19 @@ func (s *session) recordRejected(reason string) {
log.Printf("journal-milter: record rejected %s: %v", s.from, err)
}
}
// loginDomain is the domain of the session's application, "" without a login
// or when the store cannot say (fail-open, as overLimit is).
func (s *session) loginDomain() string {
if s.login == "" {
return ""
}
a, err := s.rec.ApplicationByLogin(s.login)
if err != nil {
if !errors.Is(err, store.ErrApplicationNotFound) {
log.Printf("journal-milter: domain of application %q: %v (fail-open)", s.login, err)
}
return ""
}
return strings.ToLower(a.DomainName)
}