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
+3 -1
View File
@@ -719,12 +719,14 @@ the argument.
### 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
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
domain's own message, which must pass. security.md § Mail path records
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,
as the outbound session does); the milter's inserts count toward 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
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.
- **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**
(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