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:
@@ -221,6 +221,11 @@ func TestBracedMacros(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// app1AtExample is the application the rate-limit tests sign in as: the
|
||||
// domain limit is its login's domain's, whatever MAIL FROM says (audit
|
||||
// 2026-10-17, M1).
|
||||
var app1AtExample = map[string]store.Application{"app1": {Login: "app1", DomainName: "example.com"}}
|
||||
|
||||
// limitIP is the client IP rate-limit tests connect from.
|
||||
const limitIP = "203.0.113.7"
|
||||
|
||||
@@ -249,6 +254,7 @@ func mailFrom(t *testing.T, rec Store, ip, from, login string) milter.Response {
|
||||
|
||||
func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
apps: app1AtExample,
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||
},
|
||||
@@ -276,6 +282,7 @@ func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
|
||||
|
||||
func TestRateLimitAllowsUnderLimit(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
apps: app1AtExample,
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||
},
|
||||
@@ -291,6 +298,7 @@ func TestRateLimitAllowsUnderLimit(t *testing.T) {
|
||||
|
||||
func TestRateLimitDomainAppliesToAnyIP(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
apps: app1AtExample,
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||
},
|
||||
@@ -304,6 +312,7 @@ func TestRateLimitDomainAppliesToAnyIP(t *testing.T) {
|
||||
|
||||
func TestRateLimitAppSkipsDomainWhenActive(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
apps: app1AtExample,
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
|
||||
store.RateLimitScopeApp + "|app1": appLimit(),
|
||||
@@ -364,6 +373,7 @@ func TestAuthIPRestrictOffAllowsAnyIP(t *testing.T) {
|
||||
|
||||
func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
apps: app1AtExample,
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": {}, // no max/window
|
||||
},
|
||||
@@ -383,6 +393,7 @@ func TestRateLimitFailsOpenOnLookupError(t *testing.T) {
|
||||
|
||||
func TestRateLimitFailsOpenOnCountError(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
apps: app1AtExample,
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||
},
|
||||
@@ -398,6 +409,7 @@ func TestRateLimitFailsOpenOnCountError(t *testing.T) {
|
||||
// through, which is how the second MAIL FROM of a connection got past a limit.
|
||||
func TestRateLimitAppliesWithoutAClientIP(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
apps: app1AtExample,
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||
},
|
||||
@@ -484,6 +496,7 @@ func mailFromIn(t *testing.T, rec Store, fl *inflight, ip, from, login string) (
|
||||
|
||||
func limitedRecorder(count int64) *fakeRecorder {
|
||||
return &fakeRecorder{
|
||||
apps: app1AtExample,
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||
},
|
||||
@@ -603,6 +616,7 @@ func TestReservationReleasedOnAbort(t *testing.T) {
|
||||
// no domain reservation should linger after the refusal.
|
||||
func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
apps: app1AtExample,
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||
store.RateLimitScopeApp + "|app1": appLimit(),
|
||||
@@ -717,3 +731,46 @@ func TestRateLimitHoldsTheNullSenderToItsLoginsDomain(t *testing.T) {
|
||||
t.Fatalf("a null sender over its domain's limit = %v, want TempFail (4xx)", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// The domain limit is the login's domain's. MAIL FROM reaches the milter
|
||||
// before Postfix holds it to the login, so a stranger with no login, or
|
||||
// another tenant's application, naming a victim's domain there must take
|
||||
// none of its slots, and write no refusal under it (security audit
|
||||
// 2026-10-17, M1).
|
||||
func TestRateLimitIsNotReservedByAnotherSendersMailFrom(t *testing.T) {
|
||||
victim := func() *fakeRecorder {
|
||||
return &fakeRecorder{
|
||||
apps: map[string]store.Application{
|
||||
"victim-app": {Login: "victim-app", DomainName: "victim.example"},
|
||||
"tenant-a": {Login: "tenant-a", DomainName: "tenant-a.example"},
|
||||
},
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|victim.example": {MaxMessages: 1, WindowSeconds: 3600},
|
||||
},
|
||||
counts: map[string]int64{},
|
||||
}
|
||||
}
|
||||
for name, login := range map[string]string{"a stranger with no login": "", "another tenant's application": "tenant-a"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rec := victim()
|
||||
fl := &inflight{}
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, resp := mailFromIn(t, rec, fl, limitIP, "x@victim.example", login); resp != milter.RespContinue {
|
||||
t.Fatalf("MAIL FROM %d = %v, want Continue", i, resp)
|
||||
}
|
||||
}
|
||||
if n := fl.count(store.RateLimitScopeDomain+"|victim.example", time.Now().Add(-time.Hour)); n != 0 {
|
||||
t.Errorf("%d slots of victim.example taken", n)
|
||||
}
|
||||
// The victim's own application still has its one message.
|
||||
if _, resp := mailFromIn(t, rec, fl, limitIP, "news@victim.example", "victim-app"); resp != milter.RespContinue {
|
||||
t.Errorf("the victim's own message = %v, want Continue", resp)
|
||||
}
|
||||
for _, r := range rec.rejected {
|
||||
if r.Domain == "victim.example" {
|
||||
t.Errorf("a refusal was written under the victim's domain: %+v", r)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user