package webui import ( "net/http" "sync" "time" ) // ipRateLimiter is a small in-memory fixed-window counter — same map+mutex shape as // pgpKeyCache elsewhere in this package. Bounds how many login POSTs a single source // IP can make per window, independent of the per-account lockout in login.go/ // webmail_login.go (that one tracks failures against one identifier from any IP; // this one bounds request volume from one IP regardless of which account(s) it's // trying — the two layers catch different attack shapes: a botnet spreading guesses // across many accounts, versus one machine hammering a single account). type ipRateLimiter struct { mu sync.Mutex limit int window time.Duration counts map[string]*ipWindow } type ipWindow struct { count int windowEnds time.Time } func newIPRateLimiter(limit int, window time.Duration) *ipRateLimiter { return &ipRateLimiter{limit: limit, window: window, counts: map[string]*ipWindow{}} } // allow reports whether ip may make another request right now, incrementing its // count as a side effect. Expired windows reset lazily on next access rather than // via a background sweep — fine at this app's scale (a handful of login attempts // per real user), and avoids a goroutine that outlives the App's own lifecycle. func (l *ipRateLimiter) allow(ip string) bool { l.mu.Lock() defer l.mu.Unlock() now := time.Now() w, ok := l.counts[ip] if !ok || now.After(w.windowEnds) { w = &ipWindow{count: 0, windowEnds: now.Add(l.window)} l.counts[ip] = w } w.count++ return w.count <= l.limit } // rateLimitLogin replies 429 and returns false if the request's source IP has // exceeded the per-IP login rate limit — callers should return immediately without // touching the DB or checking a password when this returns false. func (a *App) rateLimitLogin(w http.ResponseWriter, r *http.Request) bool { if a.loginLimiter.allow(a.requestIP(r)) { return true } http.Error(w, "Too many login attempts — try again in a minute.", http.StatusTooManyRequests) return false } // accountLocked reports whether authType/identifier has accumulated enough recent // failures (from any IP — see ratelimit.go's doc comment for why that's the point) // to refuse another attempt right now, per the [Auth] login_attempt_limit/window_minutes // config. Fails open (returns false) on a DB error rather than locking everyone out // over a transient issue. func (a *App) accountLocked(authType, identifier string) bool { limit := a.Cfg.Section("Auth").Key("login_attempt_limit").MustInt(8) windowMinutes := a.Cfg.Section("Auth").Key("login_attempt_window_minutes").MustInt(15) since := time.Now().Add(-time.Duration(windowMinutes) * time.Minute) n, err := a.DB.CountRecentFailedAttempts(authType, identifier, since) if err != nil { return false } return n >= limit }