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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user