Files
3x-ui/internal/web/controller/login_limiter.go
T
MHSanaei 8e8541a5d8 fix(web): count login attempts per address and while they are checked
The login limiter counted failures per (address, username) only after
the check finished, so attempts sent together all passed before the
first failure was recorded, and one address could own every record of
the bounded table and push out the one holding its own failures.

An admitted attempt now holds a slot until it reports back, and an
address is blocked as a whole after five times the per-username limit
spread over any names, which also caps the records one peer can own.
2026-10-31 09:04:23 +00:00

195 lines
5.8 KiB
Go

package controller
import (
"strings"
"sync"
"time"
)
const (
loginLimitMaxFailures = 5
loginLimitWindow = 5 * time.Minute
loginLimitCooldown = 15 * time.Minute
// Hard ceiling on tracked (ip, username) records. The key includes the
// caller-supplied username, so an unauthenticated attacker rotating
// usernames would otherwise grow the map without bound.
loginLimitMaxRecords = 10000
// Failures one address may spread over all usernames, as a multiple of the per-username
// limit, before the address is blocked as a whole: bounds the records one peer can own.
loginLimitIPFactor = 5
)
var defaultLoginLimiter = newLoginLimiter(loginLimitMaxFailures, loginLimitWindow, loginLimitCooldown)
type loginLimiter struct {
mu sync.Mutex
now func() time.Time
maxFailures int
window time.Duration
cooldown time.Duration
attempts map[string]*loginLimitRecord
ipAttempts map[string]*loginLimitRecord
}
// loginLimitRecord counts failures and admitted attempts still being checked: a burst
// sent at once would otherwise all pass before bcrypt reports the first failure.
type loginLimitRecord struct {
failures []time.Time
inFlight []time.Time
blockedUntil time.Time
}
func newLoginLimiter(maxFailures int, window, cooldown time.Duration) *loginLimiter {
return &loginLimiter{
now: time.Now,
maxFailures: maxFailures,
window: window,
cooldown: cooldown,
attempts: make(map[string]*loginLimitRecord),
ipAttempts: make(map[string]*loginLimitRecord),
}
}
// allow admits an attempt and reserves a slot for it in both the (ip, username) and the
// address-wide record; the caller reports the outcome through registerFailure or registerSuccess.
func (l *loginLimiter) allow(ip, username string) (time.Time, bool) {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
ipKey := strings.TrimSpace(ip)
if blockedUntil, ok := l.admit(l.ipAttempts, ipKey, l.maxFailures*loginLimitIPFactor, now); !ok {
return blockedUntil, false
}
if blockedUntil, ok := l.admit(l.attempts, loginLimitKey(ip, username), l.maxFailures, now); !ok {
l.ipAttempts[ipKey].dropReservation()
return blockedUntil, false
}
return time.Time{}, true
}
// admit refuses a blocked key, or one whose failures and pending attempts already fill the
// budget (starting its cooldown), and otherwise reserves a slot. Callers hold l.mu.
func (l *loginLimiter) admit(records map[string]*loginLimitRecord, key string, limit int, now time.Time) (time.Time, bool) {
record := records[key]
if record == nil {
l.evictForRoom(records, now)
record = &loginLimitRecord{}
records[key] = record
}
if now.Before(record.blockedUntil) {
return record.blockedUntil, false
}
record.blockedUntil = time.Time{}
record.prune(now.Add(-l.window))
if len(record.failures)+len(record.inFlight) >= limit {
record.block(now.Add(l.cooldown))
return record.blockedUntil, false
}
record.inFlight = append(record.inFlight, now)
return time.Time{}, true
}
func (l *loginLimiter) registerFailure(ip, username string) (time.Time, bool) {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
ipBlockedUntil, ipBlocked := l.fail(l.ipAttempts, strings.TrimSpace(ip), l.maxFailures*loginLimitIPFactor, now)
blockedUntil, blocked := l.fail(l.attempts, loginLimitKey(ip, username), l.maxFailures, now)
if blocked {
return blockedUntil, true
}
return ipBlockedUntil, ipBlocked
}
func (l *loginLimiter) fail(records map[string]*loginLimitRecord, key string, limit int, now time.Time) (time.Time, bool) {
record := records[key]
if record == nil {
l.evictForRoom(records, now)
record = &loginLimitRecord{}
records[key] = record
}
record.prune(now.Add(-l.window))
record.dropReservation()
record.failures = append(record.failures, now)
if len(record.failures) >= limit {
record.block(now.Add(l.cooldown))
return record.blockedUntil, true
}
return time.Time{}, false
}
func (l *loginLimiter) registerSuccess(ip, username string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.attempts, loginLimitKey(ip, username))
if record := l.ipAttempts[strings.TrimSpace(ip)]; record != nil {
record.dropReservation()
}
}
func (r *loginLimitRecord) prune(cutoff time.Time) {
r.failures = pruneLoginFailures(r.failures, cutoff)
r.inFlight = pruneLoginFailures(r.inFlight, cutoff)
}
func (r *loginLimitRecord) block(until time.Time) {
r.failures = nil
r.inFlight = nil
r.blockedUntil = until
}
func (r *loginLimitRecord) dropReservation() {
if r != nil && len(r.inFlight) > 0 {
r.inFlight = r.inFlight[1:]
}
}
// evictForRoom keeps a records map bounded before inserting a new record.
// It first reclaims records that are no longer blocked and whose failures have
// aged out of the window; if the map is still at the ceiling (a genuine
// broad flood), it drops one record that is neither blocked nor awaiting an
// outcome, then any, so memory can never grow past the cap. Callers hold l.mu.
func (l *loginLimiter) evictForRoom(records map[string]*loginLimitRecord, now time.Time) {
if len(records) < loginLimitMaxRecords {
return
}
cutoff := now.Add(-l.window)
for key, record := range records {
if now.Before(record.blockedUntil) {
continue
}
record.prune(cutoff)
if len(record.failures) == 0 && len(record.inFlight) == 0 {
delete(records, key)
}
}
if len(records) < loginLimitMaxRecords {
return
}
for key, record := range records {
if now.Before(record.blockedUntil) || len(record.inFlight) > 0 {
continue
}
delete(records, key)
return
}
for key := range records {
delete(records, key)
return
}
}
func loginLimitKey(ip, username string) string {
return strings.TrimSpace(ip) + "\x00" + strings.ToLower(strings.TrimSpace(username))
}
func pruneLoginFailures(failures []time.Time, cutoff time.Time) []time.Time {
keepFrom := 0
for keepFrom < len(failures) && failures[keepFrom].Before(cutoff) {
keepFrom++
}
return failures[keepFrom:]
}