MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
Failed logins are counted per account+address (3), per account (20) and per address (30) within 15 minutes; a full bucket answers 429 with Retry-After for 15 minutes. Locking the account+address bucket first means an attacker cannot lock the owner out from elsewhere. Unknown names count like accounts. Success clears the account buckets, a password reset (mail or admin) lifts the lock. LOGIN_PROTECTION=false switches it off. chi's RealIP believed X-Forwarded-For from anyone. The client address now comes from the headers only when the connection is from a trusted proxy: TRUSTED_PROXIES=private (default: loopback, Docker, LAN), all (old behaviour), none, or CIDRs. A proxy that sends no client address is logged once and per-address limits are skipped for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
4.2 KiB
Go
148 lines
4.2 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Brute-force protection for the login (FLAW-09). Failed attempts are counted in memory in three
|
|
// buckets; when one is full, logins for it are refused with 429 for the lock time:
|
|
// account + IP 3 failures the attacker's address is locked out of this account, the real owner
|
|
// logging in from elsewhere is not
|
|
// account 20 failures stops attacks spread over many addresses
|
|
// IP 30 failures stops one address trying many accounts
|
|
// A bucket counts failures within the lock window and starts over afterwards. A successful login
|
|
// clears the account buckets; a password reset clears the account completely. LOGIN_PROTECTION=false
|
|
// switches all of it off. Counters are lost on restart, which is fine for this purpose.
|
|
|
|
const (
|
|
loginLockWindow = 15 * time.Minute
|
|
loginLimitAccountIP = 3
|
|
loginLimitAccount = 20
|
|
loginLimitIP = 30
|
|
loginGuardCleanupEach = 5 * time.Minute
|
|
)
|
|
|
|
type loginBucket struct {
|
|
count int
|
|
windowStart time.Time
|
|
lockedUntil time.Time
|
|
}
|
|
|
|
type loginGuard struct {
|
|
enabled bool
|
|
now func() time.Time
|
|
|
|
mu sync.Mutex
|
|
buckets map[string]*loginBucket
|
|
lastCleanup time.Time
|
|
}
|
|
|
|
func newLoginGuard(enabled bool) *loginGuard {
|
|
return &loginGuard{enabled: enabled, now: time.Now, buckets: map[string]*loginBucket{}}
|
|
}
|
|
|
|
type loginKey struct {
|
|
key string
|
|
limit int
|
|
}
|
|
|
|
// loginKeys: account is the user ID (or the typed login for unknown names, so both look the same).
|
|
// The IP buckets are left out when the IP is not known (proxy without client header).
|
|
func loginKeys(account string, ip clientIPInfo) []loginKey {
|
|
keys := []loginKey{{"acct:" + account, loginLimitAccount}}
|
|
if !ip.viaProxyWithoutHeader && ip.ip != "" {
|
|
keys = append(keys, loginKey{"acctip:" + account + "|" + ip.ip, loginLimitAccountIP}, loginKey{"ip:" + ip.ip, loginLimitIP})
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// lockedFor returns how long the login is still refused (0 = allowed).
|
|
func (g *loginGuard) lockedFor(account string, ip clientIPInfo) time.Duration {
|
|
if !g.enabled {
|
|
return 0
|
|
}
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
now := g.now()
|
|
var wait time.Duration
|
|
for _, k := range loginKeys(account, ip) {
|
|
if b := g.buckets[k.key]; b != nil && b.lockedUntil.After(now) && b.lockedUntil.Sub(now) > wait {
|
|
wait = b.lockedUntil.Sub(now)
|
|
}
|
|
}
|
|
return wait
|
|
}
|
|
|
|
func (g *loginGuard) recordFailure(account string, ip clientIPInfo) {
|
|
if !g.enabled {
|
|
return
|
|
}
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
now := g.now()
|
|
g.cleanupLocked(now)
|
|
for _, k := range loginKeys(account, ip) {
|
|
b := g.buckets[k.key]
|
|
if b == nil || now.Sub(b.windowStart) > loginLockWindow {
|
|
b = &loginBucket{windowStart: now}
|
|
g.buckets[k.key] = b
|
|
}
|
|
b.count++
|
|
if b.count >= k.limit {
|
|
b.lockedUntil = now.Add(loginLockWindow)
|
|
}
|
|
}
|
|
}
|
|
|
|
// recordSuccess clears the counters of this account (the IP bucket stays, so one valid login cannot
|
|
// be used to keep trying other accounts).
|
|
func (g *loginGuard) recordSuccess(account string, ip clientIPInfo) {
|
|
if !g.enabled {
|
|
return
|
|
}
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
delete(g.buckets, "acct:"+account)
|
|
delete(g.buckets, "acctip:"+account+"|"+ip.ip)
|
|
}
|
|
|
|
// unlockAccount lifts every lock of the account, e.g. after a password reset by mail or by an admin.
|
|
func (g *loginGuard) unlockAccount(account string) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
for k := range g.buckets {
|
|
if k == "acct:"+account || strings.HasPrefix(k, "acctip:"+account+"|") {
|
|
delete(g.buckets, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (g *loginGuard) cleanupLocked(now time.Time) {
|
|
if now.Sub(g.lastCleanup) < loginGuardCleanupEach {
|
|
return
|
|
}
|
|
g.lastCleanup = now
|
|
for k, b := range g.buckets {
|
|
if now.Sub(b.windowStart) > loginLockWindow && !b.lockedUntil.After(now) {
|
|
delete(g.buckets, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeLoginLocked(w http.ResponseWriter, wait time.Duration) {
|
|
minutes := int(math.Ceil(wait.Minutes()))
|
|
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
|
|
unit := "Minuten"
|
|
if minutes == 1 {
|
|
unit = "Minute"
|
|
}
|
|
writeError(w, http.StatusTooManyRequests,
|
|
fmt.Sprintf("Zu viele fehlgeschlagene Anmeldeversuche. Die Anmeldung ist für noch %d %s gesperrt.", minutes, unit))
|
|
}
|