release: 1.9.0
test / test (push) Waiting to run

Application client IP allow-list restricts which addresses may submit as a SASL login; level-2 rate limits override the domain ceiling per application (higher or lower, capped at L1). Migration 0009, authips form, milter enforcement, export/import, and operator docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-18 23:51:41 +03:00
parent a8ded7ecc8
commit b4a9b93cf2
26 changed files with 457 additions and 252 deletions
+31
View File
@@ -0,0 +1,31 @@
package milter
import (
"errors"
"log"
"github.com/mixeme/selfpost/internal/store"
)
// 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).
func (s *session) authIPAllowed() bool {
if s.login == "" || s.clientIP == "" {
return true
}
a, err := s.rec.ApplicationByLogin(s.login)
if err != nil {
if errors.Is(err, store.ErrApplicationNotFound) {
return true
}
log.Printf("journal-milter: auth IP lookup application %q: %v (fail-open)", s.login, err)
return true
}
if a.AllowsAuthFromIP(s.clientIP) {
return true
}
log.Printf("journal-milter: application %q refused from %s — client IP not allowed", s.login, s.clientIP)
return false
}
+5
View File
@@ -35,6 +35,7 @@ type Store interface {
InsertRejected(e store.SendLogEntry) error
RateLimit(scope, ref string) (store.RateLimit, bool, error)
CountMessages(scope, ref string, since time.Time) (int64, error)
ApplicationByLogin(login string) (store.Application, error)
}
// session accumulates the fields of one message as the milter callbacks fire.
@@ -84,6 +85,10 @@ func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, er
s.login = macro(m, "auth_authen")
s.rcpts = nil
s.subject = ""
if !s.authIPAllowed() {
s.recordRejected()
return milter.RespTempFail, nil
}
if s.overLimit() {
s.recordRejected()
return milter.RespTempFail, nil
+52 -31
View File
@@ -25,10 +25,12 @@ type fakeRecorder struct {
fail bool
// limits, keyed by "scope|ref", drive the level-2 rate-limit tests. counts
// gives the recent-message count returned for a "scope|ref". lookupErr and
// countErr force the store errors that must fail open.
// gives the recent-message count returned for a "scope|ref". apps supplies
// application rows for client-IP authorization tests. lookupErr and countErr
// force the store errors that must fail open.
limits map[string]store.RateLimit
counts map[string]int64
apps map[string]store.Application
lookupErr error
countErr error
@@ -75,6 +77,17 @@ func (f *fakeRecorder) CountMessages(scope, ref string, _ time.Time) (int64, err
return f.counts[scope+"|"+ref], nil
}
func (f *fakeRecorder) ApplicationByLogin(login string) (store.Application, error) {
if f.lookupErr != nil {
return store.Application{}, f.lookupErr
}
a, ok := f.apps[login]
if !ok {
return store.Application{}, store.ErrApplicationNotFound
}
return a, nil
}
func mods(kv map[string]string) *milter.Modifier {
return &milter.Modifier{Macros: kv}
}
@@ -211,8 +224,8 @@ func domainLimit() store.RateLimit {
return store.RateLimit{MaxMessages: 5, WindowSeconds: 3600}
}
func appLimit(ips ...string) store.RateLimit {
return store.RateLimit{AllowedIPs: ips, MaxMessages: 5, WindowSeconds: 3600}
func appLimit() store.RateLimit {
return store.RateLimit{MaxMessages: 5, WindowSeconds: 3600}
}
// mailFrom drives just the connect + MAIL FROM stages and returns the response,
@@ -248,7 +261,7 @@ func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeApp + "|app1": appLimit(limitIP),
store.RateLimitScopeApp + "|app1": appLimit(),
},
counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max
}
@@ -286,13 +299,11 @@ func TestRateLimitDomainAppliesToAnyIP(t *testing.T) {
}
}
func TestRateLimitTrustedAppSkipsDomain(t *testing.T) {
func TestRateLimitAppSkipsDomainWhenActive(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
store.RateLimitScopeApp + "|app1": {
AllowedIPs: []string{limitIP}, MaxMessages: 10, WindowSeconds: 3600,
},
store.RateLimitScopeApp + "|app1": appLimit(),
},
counts: map[string]int64{
store.RateLimitScopeDomain + "|example.com": 5, // over domain
@@ -300,41 +311,51 @@ func TestRateLimitTrustedAppSkipsDomain(t *testing.T) {
},
}
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
t.Fatalf("trusted app under its ceiling = %v, want Continue (domain skipped)", resp)
t.Fatalf("app under its ceiling = %v, want Continue (domain not checked)", resp)
}
}
func TestRateLimitUnlistedIPHitsDomain(t *testing.T) {
func TestAuthIPRestrictBlocksUnlisted(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
store.RateLimitScopeApp + "|app1": {
AllowedIPs: []string{"198.51.100.1"}, MaxMessages: 100, WindowSeconds: 3600,
apps: map[string]store.Application{
"app1": {
Login: "app1",
AuthIPRestrict: true,
AuthAllowedIPs: []string{"198.51.100.1"},
},
},
counts: map[string]int64{
store.RateLimitScopeDomain + "|example.com": 1,
store.RateLimitScopeApp + "|app1": 0,
},
}
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
t.Fatalf("unlisted IP under domain = %v, want TempFail", resp)
t.Fatalf("unlisted IP = %v, want TempFail", resp)
}
if len(rec.rejected) != 1 {
t.Fatalf("want one rejected row, got %+v", rec.rejected)
}
}
func TestRateLimitAppWithoutIPsDoesNotPrivilege(t *testing.T) {
func TestAuthIPRestrictAllowsListed(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
store.RateLimitScopeApp + "|app1": {MaxMessages: 100, WindowSeconds: 3600}, // no IPs
},
counts: map[string]int64{
store.RateLimitScopeDomain + "|example.com": 1,
store.RateLimitScopeApp + "|app1": 0,
apps: map[string]store.Application{
"app1": {
Login: "app1",
AuthIPRestrict: true,
AuthAllowedIPs: []string{limitIP},
},
},
}
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
t.Fatalf("app without IPs must not skip domain = %v, want TempFail", resp)
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
t.Fatalf("listed IP = %v, want Continue", resp)
}
}
func TestAuthIPRestrictOffAllowsAnyIP(t *testing.T) {
rec := &fakeRecorder{
apps: map[string]store.Application{
"app1": {Login: "app1"},
},
}
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
t.Fatalf("restriction off = %v, want Continue", resp)
}
}
@@ -525,7 +546,7 @@ func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": domainLimit(),
store.RateLimitScopeApp + "|app1": appLimit(limitIP),
store.RateLimitScopeApp + "|app1": appLimit(),
},
counts: map[string]int64{
store.RateLimitScopeDomain + "|example.com": 0,
+5 -4
View File
@@ -10,9 +10,10 @@ import (
// overLimit reports whether the message currently being received should be
// refused under a level-2 differentiated limit (guide § Rate limiting).
//
// Trusted application IPs (app limit active and client IP listed) use only the
// app ceiling and skip the domain check. Everyone else is under the domain
// ceiling when one is configured; otherwise only level 1 applies.
// When an application has an active limit, it overrides the domain limit for
// that login (the ceiling may be higher or lower than the domain). Otherwise
// the domain ceiling applies when configured; if neither is set, only level 1
// applies.
//
// It is deliberately fail-open: any store error, or the absence of a usable
// limit, is treated as "not over limit" so a malfunction of the level-2
@@ -33,7 +34,7 @@ func (s *session) overLimit() bool {
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeApp, s.login)
if err != nil {
log.Printf("journal-milter: rate-limit lookup application %q: %v (fail-open)", s.login, err)
} else if ok && rl.Active() && rl.AllowsIP(s.clientIP) {
} else if ok && rl.Active() {
return s.enforceLimit(store.RateLimitScopeApp, s.login, rl)
}
}