panel,mail: fail closed on the rate-limit race, session create and app delete
test / test (push) Has been cancelled

The level-2 limiter counted stored plus in-flight messages and reserved its own slot in two critical sections, so SMTP sessions that overlapped could each take the last free slot; tryAdmit now does both under one lock. A session that cannot be written no longer yields a cookie the browser would carry while every request bounced to /login. Deleting an application clears its SASL account before its registry row, matching domain delete, so a saslpasswd2 failure leaves a retryable application rather than an account that still authenticates.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-13 14:50:04 +03:00
parent 8355479e03
commit 4761991dd5
10 changed files with 306 additions and 31 deletions
+30 -8
View File
@@ -36,6 +36,29 @@ type inflight struct {
m map[string]map[*reservation]struct{}
}
// tryAdmit decides whether one more message may be sent for key and, if so,
// claims its slot — both under a single lock. Counting and reserving in two
// separate critical sections would let two SMTP sessions that reach MAIL FROM
// at the same moment observe the same total and each take the last free slot,
// which is exactly the overshoot the in-flight registry exists to prevent.
//
// stored is the count the send log already holds for the limit's window and max
// is the ceiling; the caller supplies both because only it can query the store.
// The returned total is what was measured, for the refusal log line.
func (f *inflight) tryAdmit(key string, since time.Time, stored, max int64) (*reservation, int64, bool) {
if f == nil {
return nil, stored, stored < max // no in-flight accounting (tests)
}
f.mu.Lock()
defer f.mu.Unlock()
total := stored + f.countLocked(key, since)
if total >= max {
return nil, total, false
}
return f.reserveLocked(key), total, true
}
// count returns how many reservations for key were taken within the limit's
// window (at or after since), pruning any that outlived reservationTTL.
func (f *inflight) count(key string, since time.Time) int64 {
@@ -45,6 +68,11 @@ func (f *inflight) count(key string, since time.Time) int64 {
f.mu.Lock()
defer f.mu.Unlock()
return f.countLocked(key, since)
}
// countLocked is count's body; the caller holds mu.
func (f *inflight) countLocked(key string, since time.Time) int64 {
set := f.m[key]
cutoff := time.Now().Add(-reservationTTL)
var n int64
@@ -63,14 +91,8 @@ func (f *inflight) count(key string, since time.Time) int64 {
return n
}
// reserve claims a slot for key until the message is recorded or released.
func (f *inflight) reserve(key string) *reservation {
if f == nil {
return nil
}
f.mu.Lock()
defer f.mu.Unlock()
// reserveLocked is reserve's body; the caller holds mu.
func (f *inflight) reserveLocked(key string) *reservation {
if f.m == nil {
f.m = make(map[string]map[*reservation]struct{})
}