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
+5
View File
@@ -13,6 +13,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
check. Adding a user, setting a password, changing an e-mail or a role on check. Adding a user, setting a password, changing an e-mail or a role on
the user form, and deleting a user now ask for your own account password, the user form, and deleting a user now ask for your own account password,
as a backup does, under the same limit on wrong guesses. as a backup does, under the same limit on wrong guesses.
- **A domain's rate limit is taken only by its own applications** (audit
M1): the level-2 domain limit was read from `MAIL FROM`, which reaches the
milter before Postfix holds it to the login, so a client that never
signed in, or another tenant's application, could use up a domain's
limit. It is now the signed-in application's domain.
- **A stranger's recipient no longer writes an Inbound › Log row** (audit - **A stranger's recipient no longer writes an Inbound › Log row** (audit
M2): Postfix quotes a refused recipient in its reply with the quotes M2): Postfix quotes a refused recipient in its reply with the quotes
removed, so `RCPT TO:<"; from=<x> to=<y> …"@domain>` spelled a second removed, so `RCPT TO:<"; from=<x> to=<y> …"@domain>` spelled a second
+3 -1
View File
@@ -719,12 +719,14 @@ the argument.
### Stage 1 — the tenant boundary in the mail path (~7 h) → patch release ### Stage 1 — the tenant boundary in the mail path (~7 h) → patch release
- [ ] **M1.** `overLimit` and `recordRejected` take the domain from the login - [x] **M1.** `overLimit` and `recordRejected` take the domain from the login
(`ApplicationByLogin`), return early on an empty login; the reader's two (`ApplicationByLogin`), return early on an empty login; the reader's two
tests (stranger; other tenant) become the regression tests; an e2e sends an tests (stranger; other tenant) become the regression tests; an e2e sends an
unauthenticated `MAIL FROM` for a domain with a limit of 1 and then the unauthenticated `MAIL FROM` for a domain with a limit of 1 and then the
domain's own message, which must pass. security.md § Mail path records domain's own message, which must pass. security.md § Mail path records
that an unauthenticated session reserves nothing. — **Opus**, 2 h. that an unauthenticated session reserves nothing. — **Opus**, 2 h.
*Done 2026-10-17:* TestRateLimitIsNotReservedByAnotherSendersMailFrom;
e2e `domain_limit_not_reserved_by_a_stranger`.
- [ ] **M3.** De-duplicate recipients in `inboundSession.RcptTo` (case-folded, - [ ] **M3.** De-duplicate recipients in `inboundSession.RcptTo` (case-folded,
as the outbound session does); the milter's inserts count toward the prune as the outbound session does); the milter's inserts count toward the prune
trigger (a counter in the store beside `InsertInboundSpam`, the prune trigger (a counter in the store beside `InsertInboundSpam`, the prune
+10
View File
@@ -399,6 +399,16 @@ operator can act on it.
ask for it at all, an application with a client-address list is refused when ask for it at all, an application with a client-address list is refused when
no address is known, and neither milter replies anything but "continue" to a no address is known, and neither milter replies anything but "continue" to a
message it lets through. `test/e2e` sends two `MAIL FROM` on one connection. message it lets through. `test/e2e` sends two `MAIL FROM` on one connection.
- **A level-2 limit is reserved only by its own senders.** The domain limit
is the domain of the signed-in application, never the `MAIL FROM`
domain, and a session with no login reserves nothing. `MAIL FROM` reaches
the journal milter before Postfix holds it to the login (with
`smtpd_delay_reject` at its default no sender restriction runs at MAIL),
so until `2.14.4` a stranger on 465/587 who never signed in, or another
tenant's application, could name a domain there and take its slots for
the in-flight TTL - and have a `limit` refusal written under it (security
audit 2026-10-17, M1). `test/e2e` names a limited domain in a stranger's
`MAIL FROM` and then sends the domain's own message.
- **The upstream's answer is an update of a row, not a row** - **The upstream's answer is an update of a row, not a row**
(inbound-upstream-result, `2.10.0`). A delivery line of mail accepted on (inbound-upstream-result, `2.10.0`). A delivery line of mail accepted on
port 25 changes the result of that message's journal rows and adds port 25 changes the result of that message's journal rows and adds
+57
View File
@@ -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. // limitIP is the client IP rate-limit tests connect from.
const limitIP = "203.0.113.7" 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) { func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
apps: app1AtExample,
limits: map[string]store.RateLimit{ limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": domainLimit(), store.RateLimitScopeDomain + "|example.com": domainLimit(),
}, },
@@ -276,6 +282,7 @@ func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
func TestRateLimitAllowsUnderLimit(t *testing.T) { func TestRateLimitAllowsUnderLimit(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
apps: app1AtExample,
limits: map[string]store.RateLimit{ limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": domainLimit(), store.RateLimitScopeDomain + "|example.com": domainLimit(),
}, },
@@ -291,6 +298,7 @@ func TestRateLimitAllowsUnderLimit(t *testing.T) {
func TestRateLimitDomainAppliesToAnyIP(t *testing.T) { func TestRateLimitDomainAppliesToAnyIP(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
apps: app1AtExample,
limits: map[string]store.RateLimit{ limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": domainLimit(), store.RateLimitScopeDomain + "|example.com": domainLimit(),
}, },
@@ -304,6 +312,7 @@ func TestRateLimitDomainAppliesToAnyIP(t *testing.T) {
func TestRateLimitAppSkipsDomainWhenActive(t *testing.T) { func TestRateLimitAppSkipsDomainWhenActive(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
apps: app1AtExample,
limits: map[string]store.RateLimit{ limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600}, store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
store.RateLimitScopeApp + "|app1": appLimit(), store.RateLimitScopeApp + "|app1": appLimit(),
@@ -364,6 +373,7 @@ func TestAuthIPRestrictOffAllowsAnyIP(t *testing.T) {
func TestRateLimitInactiveWithoutCeiling(t *testing.T) { func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
apps: app1AtExample,
limits: map[string]store.RateLimit{ limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": {}, // no max/window store.RateLimitScopeDomain + "|example.com": {}, // no max/window
}, },
@@ -383,6 +393,7 @@ func TestRateLimitFailsOpenOnLookupError(t *testing.T) {
func TestRateLimitFailsOpenOnCountError(t *testing.T) { func TestRateLimitFailsOpenOnCountError(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
apps: app1AtExample,
limits: map[string]store.RateLimit{ limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": domainLimit(), 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. // through, which is how the second MAIL FROM of a connection got past a limit.
func TestRateLimitAppliesWithoutAClientIP(t *testing.T) { func TestRateLimitAppliesWithoutAClientIP(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
apps: app1AtExample,
limits: map[string]store.RateLimit{ limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": domainLimit(), 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 { func limitedRecorder(count int64) *fakeRecorder {
return &fakeRecorder{ return &fakeRecorder{
apps: app1AtExample,
limits: map[string]store.RateLimit{ limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": domainLimit(), store.RateLimitScopeDomain + "|example.com": domainLimit(),
}, },
@@ -603,6 +616,7 @@ func TestReservationReleasedOnAbort(t *testing.T) {
// no domain reservation should linger after the refusal. // no domain reservation should linger after the refusal.
func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) { func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) {
rec := &fakeRecorder{ rec := &fakeRecorder{
apps: app1AtExample,
limits: map[string]store.RateLimit{ limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": domainLimit(), store.RateLimitScopeDomain + "|example.com": domainLimit(),
store.RateLimitScopeApp + "|app1": appLimit(), 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) 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)
}
}
})
}
}
+31 -9
View File
@@ -1,7 +1,9 @@
package milter package milter
import ( import (
"errors"
"log" "log"
"strings"
"time" "time"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
@@ -38,15 +40,19 @@ func (s *session) overLimit() bool {
} }
} }
domain := domainOf(s.from) // The domain is the login's, never the envelope's. MAIL FROM reaches
if domain == "" && s.login != "" { // this milter before Postfix has held it to the login - with
// MAIL FROM:<> has no domain to read; the login has one. A null // smtpd_delay_reject at its default no sender restriction runs at MAIL
// sender is held to its application's domain like any other message // (Postfix 3.10.13, src/smtpd/smtpd.c, mail_cmd()) - so a domain read
// of it (review of audit stage 2). // from it let a stranger on 465/587, with no login at all, or another
if a, err := s.rec.ApplicationByLogin(s.login); err == nil { // tenant's application reserve a victim domain's slots (security audit
domain = a.DomainName // 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 == "" { if domain == "" {
return false return false
} }
@@ -99,7 +105,7 @@ func (s *session) releaseReservations() {
// and never affects the response. // and never affects the response.
func (s *session) recordRejected(reason string) { func (s *session) recordRejected(reason string) {
err := s.rec.InsertRejected(store.SendLogEntry{ err := s.rec.InsertRejected(store.SendLogEntry{
Domain: domainOf(s.from), Domain: s.loginDomain(),
AppLogin: s.login, AppLogin: s.login,
From: s.from, From: s.from,
Reason: reason, Reason: reason,
@@ -109,3 +115,19 @@ func (s *session) recordRejected(reason string) {
log.Printf("journal-milter: record rejected %s: %v", s.from, err) 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)
}
+97
View File
@@ -0,0 +1,97 @@
package e2e
import (
"crypto/tls"
"fmt"
"net"
"net/http"
"net/smtp"
"net/url"
"testing"
"time"
)
// limitDomain is a sending domain of its own for the domain-limit case, so
// that its ceiling of one touches no other step.
const limitDomain = "limit.e2e.test"
// testDomainLimitNotReservedByAStranger is the "done when" of the security
// audit's M1 (2026-10-17): the level-2 domain limit is the login's domain's.
// MAIL FROM reaches the journal milter before Postfix holds it to a login, so
// a client with no login naming a domain there used to take that domain's
// slots. Here a domain has a limit of one message an hour; a stranger opens
// the submission port, names the domain in MAIL FROM three times without
// signing in, and the domain's own application still sends its one message -
// and is refused the second, so the limit is in force.
func testDomainLimitNotReservedByAStranger(t *testing.T, sc *scenario) {
domainID, err := sc.panel.addDomain(limitDomain)
if err != nil {
t.Fatalf("add %s: %v", limitDomain, err)
}
login, password, err := sc.panel.addApplication(domainID, "limit-app", "wildcard", "")
if err == nil {
err = waitForSenderMap(login, password, "alerts@"+limitDomain)
}
if err != nil {
t.Fatalf("add the application: %v", err)
}
resp, body, err := sc.panel.postForm("/outbound/domains/"+domainID+"/settings/ratelimit", url.Values{
"mode": {"manual"}, "max_messages": {"1"}, "window_seconds": {"3600"},
})
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK || resp.Request.URL.Path != "/outbound/domains/"+domainID {
t.Fatalf("save the domain limit: status %d, landed on %s:\n%.800s", resp.StatusCode, resp.Request.URL.Path, body)
}
if err := strangerMailFrom("x@"+limitDomain, 3); err != nil {
t.Fatalf("the stranger's MAIL FROM: %v", err)
}
send := func(token string) sendResult {
return attemptSend(sendAttempt{
authLogin: login, authPassword: password,
from: "alerts@" + limitDomain, to: recipient, subject: "e2e " + uniqueToken(token), body: "ok",
})
}
if res := send("m1-own"); !res.ok() {
t.Fatalf("the domain's own message after a stranger's MAIL FROM: %v", res.firstErr())
}
if err := send("m1-second").refusedAt("MAIL", 451, "4.7.1 Service unavailable"); err != nil {
t.Fatalf("the second message over the domain's limit of one: %v", err)
}
}
// strangerMailFrom opens the implicit-TLS submission port, says EHLO and
// sends MAIL FROM:<from> n times with RSET between, never signing in. Each
// MAIL FROM is answered 250: the journal milter continues, and Postfix
// refuses such a client only at RCPT (smtpd_delay_reject).
func strangerMailFrom(from string, n int) error {
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", smtpsAddr, &tls.Config{
InsecureSkipVerify: true,
ServerName: selfpostHostname,
})
if err != nil {
return err
}
c, err := smtp.NewClient(conn, selfpostHostname)
if err != nil {
_ = conn.Close()
return err
}
defer c.Close()
if err := c.Hello("stranger.e2e.test"); err != nil {
return err
}
for i := 0; i < n; i++ {
if err := c.Mail(from); err != nil {
return fmt.Errorf("MAIL FROM %d: %w", i+1, err)
}
if err := c.Reset(); err != nil {
return err
}
}
_ = c.Quit()
return nil
}
+3
View File
@@ -315,6 +315,9 @@ func TestE2E(t *testing.T) {
run("rate_limit_refusal_is_notified", func(t *testing.T) { run("rate_limit_refusal_is_notified", func(t *testing.T) {
testRateLimitRefusalIsNotified(t, sc) testRateLimitRefusalIsNotified(t, sc)
}) })
run("domain_limit_not_reserved_by_a_stranger", func(t *testing.T) {
testDomainLimitNotReservedByAStranger(t, sc)
})
run("negative_sender_login_mismatch", func(t *testing.T) { run("negative_sender_login_mismatch", func(t *testing.T) {
testSenderLoginMismatch(t, sc) testSenderLoginMismatch(t, sc)
}) })