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
+9 -4
View File
@@ -8,11 +8,12 @@ import (
)
// authIPAllowed reports whether the authenticated application may submit from
// the connecting client IP. When the application has no IP restriction, or the
// client IP is not known, the check passes. Store errors are fail-open (see
// overLimit).
// the connecting client IP. When the application has no IP restriction the
// check passes. When it has one and the client's address is not known, the
// check fails: an address list that an unknown address satisfies is no list.
// Store errors are fail-open (see overLimit).
func (s *session) authIPAllowed() bool {
if s.login == "" || s.clientIP == "" {
if s.login == "" {
return true
}
a, err := s.rec.ApplicationByLogin(s.login)
@@ -26,6 +27,10 @@ func (s *session) authIPAllowed() bool {
if a.AllowsAuthFromIP(s.clientIP) {
return true
}
if s.clientIP == "" {
log.Printf("journal-milter: application %q refused — its client IPs are restricted and Postfix gave no client address", s.login)
return false
}
log.Printf("journal-milter: application %q refused from %s — client IP not allowed", s.login, s.clientIP)
return false
}
+12 -5
View File
@@ -27,8 +27,8 @@ type InboundStore interface {
// of a message that reached end-of-message - that is, one the filter did not
// refuse; refusals never get this far and are read from mail.log instead.
//
// Like the send-log milter it only watches: every callback continues or
// accepts, and Postfix attaches the socket with default_action=accept, so
// Like the send-log milter it only watches: every callback continues, and
// Postfix attaches the socket with default_action=accept, so
// neither an error here nor the panel being down holds up inbound mail.
type inboundSession struct {
milter.NoOpMilter
@@ -38,7 +38,10 @@ type inboundSession struct {
// by the sender, and are not read.
filter bool
clientIP string // once per connection
// clientIP is taken at Connect and again from {client_addr} with every
// MAIL FROM: a session the library made after a non-continue reply never
// sees Connect (see session.MailFrom in milter.go).
clientIP string
from string
rcpts []string
@@ -58,6 +61,9 @@ func (s *inboundSession) Connect(host, family string, port uint16, addr net.IP,
// MailFrom starts a message: a connection may carry several.
func (s *inboundSession) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
s.reset()
if ip := clientAddr(m); ip != "" {
s.clientIP = ip
}
s.from = cleanAddress(from)
return milter.RespContinue, nil
}
@@ -90,11 +96,12 @@ func (s *inboundSession) Header(name, value string, m *milter.Modifier) (milter.
}
// Body fires at end-of-message: the message is about to be queued for its
// upstream.
// upstream. "Continue" lets it through as "accept" would, and keeps this
// session for the connection's next message.
func (s *inboundSession) Body(m *milter.Modifier) (milter.Response, error) {
s.record()
s.reset()
return milter.RespAccept, nil
return milter.RespContinue, nil
}
func (s *inboundSession) Abort(m *milter.Modifier) error {
+47 -4
View File
@@ -77,8 +77,8 @@ func TestInboundRecordsAcceptedMessage(t *testing.T) {
resp := message(t, s, "<sender@example.net> SIZE=1200",
[]string{"<a@in.example.com>", "b@IN.example.com"},
[][2]string{{"Subject", "=?utf-8?Q?Sch=C3=B6n?="}})
if resp != milter.RespAccept {
t.Errorf("end-of-message response = %v, want accept", resp)
if resp != milter.RespContinue {
t.Errorf("end-of-message response = %v, want continue", resp)
}
if len(rec.rows) != 2 {
t.Fatalf("rows = %+v, want one per recipient", rec.rows)
@@ -217,8 +217,8 @@ func TestInboundAcceptsWhenTheStoreFails(t *testing.T) {
"lookup": {lookupErr: errors.New("boom")},
} {
s := inboundSessionFor(t, rec, false)
if resp := message(t, s, "sender@example.net", []string{"a@in.example.com"}, nil); resp != milter.RespAccept {
t.Errorf("%s failure: response = %v, want accept", name, resp)
if resp := message(t, s, "sender@example.net", []string{"a@in.example.com"}, nil); resp != milter.RespContinue {
t.Errorf("%s failure: response = %v, want continue", name, resp)
}
}
}
@@ -234,3 +234,46 @@ func TestRspamdVerdictKeepsTheListSymbols(t *testing.T) {
t.Errorf("detail = %q\nwant %q", detail, want)
}
}
// The client address of a connection's later messages comes from the MAIL
// macro: a session the library made after a non-continue reply saw no Connect.
func TestInboundClientAddressFromTheMailMacro(t *testing.T) {
rec := &fakeInbound{domains: map[string]bool{"in.example.com": true}}
s := &inboundSession{rec: rec} // no Connect
if _, err := s.MailFrom("sender@example.net", mods(map[string]string{"{client_addr}": "IPv6:2001:db8::7"})); err != nil {
t.Fatal(err)
}
if _, err := s.RcptTo("a@in.example.com", mods(nil)); err != nil {
t.Fatal(err)
}
if _, err := s.Body(mods(nil)); err != nil {
t.Fatal(err)
}
if len(rec.rows) != 1 || rec.rows[0].ClientIP != "2001:db8::7" {
t.Errorf("rows = %+v", rec.rows)
}
}
// No callback of the inbound milter answers anything but "continue": it must
// never be the reason a message is held up, and a non-continue reply would
// cost it its session.
func TestInboundOnlyEverContinues(t *testing.T) {
rec := &fakeInbound{domains: map[string]bool{"in.example.com": true}}
s := &inboundSession{rec: rec, filter: true}
check := func(stage string, resp milter.Response, err error) {
t.Helper()
if err != nil || resp != milter.RespContinue {
t.Errorf("%s: %v, %v", stage, resp, err)
}
}
resp, err := s.Connect("mx", "tcp4", 0, net.ParseIP("203.0.113.7"), mods(nil))
check("connect", resp, err)
resp, err = s.MailFrom("a@example.net", mods(nil))
check("mail", resp, err)
resp, err = s.RcptTo("a@in.example.com", mods(nil))
check("rcpt", resp, err)
resp, err = s.Header("X-Rspamd-Action", "reject", mods(nil))
check("header", resp, err)
resp, err = s.Body(mods(nil))
check("end of message", resp, err)
}
+39 -6
View File
@@ -53,7 +53,11 @@ type session struct {
// valid zero value (no in-flight accounting).
flight *inflight
clientIP string // captured once per connection
// clientIP is the client's address. Postfix gives it at Connect, once per
// connection, and again with every MAIL FROM as the {client_addr} macro
// (milter_mail_macros in build/postfix-config.sh). The second is the one
// that can be relied on: see MailFrom.
clientIP string
login string
from string
@@ -62,9 +66,8 @@ type session struct {
reserved []*reservation // level-2 slots held by the current message
}
// Connect captures the client IP, which comes from the addr parameter rather
// than a macro (the {client_addr} macro was empty in testing). It is the
// rate-limit key; here it is recorded for completeness.
// Connect captures the client IP of a connection's first message. It is not
// enough on its own - see MailFrom.
func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milter.Modifier) (milter.Response, error) {
if addr != nil {
s.clientIP = addr.String()
@@ -79,8 +82,21 @@ func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milt
// limit is enforced here: over the limit, the message is refused with a 4xx
// tempfail before recipients are even offered (guide § Rate limiting).
// Enforcement is fail-open — see overLimit.
//
// The client address is read again here, from the {client_addr} macro. The
// milter library replaces the session with a new one after every reply that is
// not "continue" - a refusal, or the accept that used to end a message - and
// Postfix does not send Connect again on the same connection. A session made
// that way never saw the client, so every message of a connection after the
// first, and every MAIL FROM after a refused one, used to be judged with no
// client address: the level-2 limit and an application's client-address list
// both let it through. (Found on 2026-10-12; test/e2e sends two MAIL FROM on
// one connection.)
func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
s.releaseReservations() // a previous transaction that ended without EOM/ABORT
if ip := clientAddr(m); ip != "" {
s.clientIP = ip
}
s.from = cleanAddress(from)
s.login = macro(m, "auth_authen")
s.rcpts = nil
@@ -113,13 +129,15 @@ func (s *session) Header(name, value string, m *milter.Modifier) (milter.Respons
// Body fires at end-of-message, when the queue-id macro {i} is set and the
// message is about to be committed to the queue. This is where the "queued"
// rows are written. We accept (this milter is done) without ever rejecting.
// rows are written. The reply is "continue", which at end-of-message lets the
// message through just as "accept" does - and, unlike it, leaves this session
// in place for the connection's next message (see MailFrom).
func (s *session) Body(m *milter.Modifier) (milter.Response, error) {
s.record(macro(m, "i"))
// The rows are in the send log now, so the stored count sees this message
// and its level-2 slots are no longer needed.
s.releaseReservations()
return milter.RespAccept, nil
return milter.RespContinue, nil
}
// Abort ends the current transaction without an end-of-message (client RSET, or
@@ -143,6 +161,21 @@ func macro(m *milter.Modifier, name string) string {
return m.Macros["{"+name+"}"]
}
// clientAddr is the client's address from the {client_addr} macro, or "" when
// Postfix did not send one that reads as an address. An IPv6 address may come
// in Sendmail's form, with "IPv6:" in front.
func clientAddr(m *milter.Modifier) string {
raw := strings.TrimSpace(macro(m, "client_addr"))
if len(raw) > 5 && strings.EqualFold(raw[:5], "IPv6:") {
raw = raw[5:]
}
ip := net.ParseIP(raw)
if ip == nil {
return ""
}
return ip.String()
}
// record writes one send-log row per recipient. Failures are logged, never
// propagated: journalling must not affect mail acceptance (architecture.md §
// Mail path).
+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)
}
}
+3 -4
View File
@@ -26,10 +26,9 @@ import (
// reaches the send log (or is abandoned) — see inflight for why the stored
// count alone is not enough.
func (s *session) overLimit() bool {
if s.clientIP == "" {
return false // no client IP to key on; level-2 does not apply
}
// A ceiling is on a domain or an application, whatever address the mail
// comes from, so the client address is not asked for here. It used to be,
// and a message that arrived without one was let through uncounted.
if s.login != "" {
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeApp, s.login)
if err != nil {