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:
@@ -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
|
||||
}
|
||||
@@ -315,6 +315,9 @@ func TestE2E(t *testing.T) {
|
||||
run("rate_limit_refusal_is_notified", func(t *testing.T) {
|
||||
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) {
|
||||
testSenderLoginMismatch(t, sc)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user