milter: the level-2 limit and the client IP list hold for every message

Security fix, outside the inbound antispam feature. Found by its
technical review (Opus, 2026-10-12), reproduced on the test stand
before the fix: over an application's level-2 limit, two MAIL FROM on
one connection were answered 451 and then accepted.

Cause. Postfix introduces a client to a milter once per connection.
The milter library (go-milter 0.4.1, session.go) replaces the milter's
session after any reply that is not "continue" - a refusal, or the
accept that used to end a message. The new session never saw Connect,
so it had no client address, and both checks passed a message without
one: overLimit returned early ("no client IP to key on") and
authIPAllowed did the same. So every message after the first on a
connection, and every MAIL FROM after a refused one, skipped the
level-2 limit and an application's client IP list. In the code since
1.9.0 for the list and longer for the limit.

Fix.
  - build/postfix-config.sh: milter_mail_macros carries {client_addr},
    so the address comes with every MAIL FROM; MailFrom reads it (both
    spellings of IPv6) in the journal milter and the inbound journal
    milter.
  - overLimit no longer asks for a client address: a ceiling is on a
    domain or an application.
  - authIPAllowed refuses an application that has a client IP list when
    no address is known.
  - Body replies "continue" in both milters, which lets the message
    through as "accept" did and keeps the session.

The inbound journal milter had the same cause with a smaller effect:
rows of a connection's later messages had no client address.

In the same file, the checks of INBOUND_ANTISPAM_MILTER and
INBOUND_JOURNAL_MILTER_SOCKET refuse any character outside their set
first: a glob's "*" matched anything after the first characters.

Tests: a session that never saw Connect is judged by the macro, a
listed and an unlisted address, no address, not an address; the limit
with no client address; the inbound milter answers only "continue".
test/e2e sends two MAIL FROM on one connection over the limit and
wants both refused.

Checked on the test server: e2e red before the fix on exactly that
check, green after; go vet ./... ok, go test ./... ok (22 packages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-12 07:51:50 +03:00
co-authored by Claude Opus 5.5
parent 00feb7c1c7
commit 5f86457848
9 changed files with 236 additions and 29 deletions
+64 -6
View File
@@ -166,8 +166,10 @@ func TestBodyAcceptsEvenWhenRecorderFails(t *testing.T) {
if err != nil {
t.Fatalf("Body returned error, must fail open: %v", err)
}
if resp != milter.RespAccept {
t.Fatalf("Body response = %v, want Accept", resp)
// "Continue" at end-of-message lets the message through, and keeps the
// session for the connection's next message.
if resp != milter.RespContinue {
t.Fatalf("Body response = %v, want Continue", resp)
}
}
@@ -389,21 +391,77 @@ func TestRateLimitFailsOpenOnCountError(t *testing.T) {
}
}
func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) {
// A ceiling is on a domain or an application, not on an address: a message that
// arrives with no client address is counted like any other. It used to be let
// through, which is how the second MAIL FROM of a connection got past a limit.
func TestRateLimitAppliesWithoutAClientIP(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": domainLimit(),
},
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
}
// A session with no client IP (e.g. local submission) cannot be keyed.
s := &session{rec: rec}
resp, err := s.MailFrom("a@example.com", mods(map[string]string{"auth_authen": "app1"}))
if err != nil {
t.Fatalf("MailFrom: %v", err)
}
if resp != milter.RespContinue {
t.Fatalf("no-IP session = %v, want Continue", resp)
if resp != milter.RespTempFail {
t.Fatalf("over the limit with no client IP = %v, want TempFail", resp)
}
}
// The milter library hands the milter a fresh session after every reply that
// is not "continue", and Postfix sends Connect once per connection. Such a
// session knows the client only from the {client_addr} macro of MAIL FROM, and
// that has to be enough for an application's client-address list.
func TestAuthIPRestrictHoldsForASessionThatNeverSawConnect(t *testing.T) {
rec := &fakeRecorder{apps: map[string]store.Application{
"app1": {Login: "app1", AuthIPRestrict: true, AuthAllowedIPs: []string{"198.51.100.1"}},
}}
for name, c := range map[string]struct {
macros map[string]string
want milter.Response
}{
"an unlisted address in the macro": {map[string]string{"auth_authen": "app1", "{client_addr}": limitIP}, milter.RespTempFail},
"the listed address in the macro": {map[string]string{"auth_authen": "app1", "{client_addr}": "198.51.100.1"}, milter.RespContinue},
// No address at all is not a pass: a list an unknown address satisfies is no list.
"no client address": {map[string]string{"auth_authen": "app1"}, milter.RespTempFail},
"not an address": {map[string]string{"auth_authen": "app1", "{client_addr}": "unknown"}, milter.RespTempFail},
} {
s := &session{rec: rec} // no Connect
resp, err := s.MailFrom("a@example.com", mods(c.macros))
if err != nil || resp != c.want {
t.Errorf("%s: %v, %v; want %v", name, resp, err, c.want)
}
}
// An application with no list is not held up by a missing address.
open := &fakeRecorder{apps: map[string]store.Application{"app1": {Login: "app1"}}}
s := &session{rec: open}
if resp, _ := s.MailFrom("a@example.com", mods(map[string]string{"auth_authen": "app1"})); resp != milter.RespContinue {
t.Errorf("no list, no address: %v, want Continue", resp)
}
}
// The macro wins over what Connect said, and reads both spellings of IPv6.
func TestClientAddrFromTheMailMacro(t *testing.T) {
for raw, want := range map[string]string{
"203.0.113.7": "203.0.113.7", "2001:db8::1": "2001:db8::1", "IPv6:2001:DB8::1": "2001:db8::1",
"ipv6:::1": "::1", "": "", "unknown": "", "IPv6:": "",
} {
if got := clientAddr(mods(map[string]string{"{client_addr}": raw})); got != want {
t.Errorf("clientAddr(%q) = %q, want %q", raw, got, want)
}
}
s := &session{rec: &fakeRecorder{}}
_, _ = s.Connect("h", "tcp4", 0, net.ParseIP("192.0.2.1"), mods(nil))
_, _ = s.MailFrom("a@example.com", mods(map[string]string{"client_addr": "192.0.2.2"}))
if s.clientIP != "192.0.2.2" {
t.Errorf("clientIP = %q, want the macro's", s.clientIP)
}
_, _ = s.MailFrom("a@example.com", mods(nil))
if s.clientIP != "192.0.2.2" {
t.Errorf("clientIP = %q after a MAIL FROM with no macro, want it kept", s.clientIP)
}
}