feat(auth): lock logins after repeated failures, trust proxies explicitly (FLAW-09)
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>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 14:26:02 +01:00
1 parent 6b9eb62caf
commit 11c14fa9ff
12 files changed
+503 -30

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
package api
import (
"context"
"log/slog"
"net"
"net/http"
"strings"
"sync"
)
// Client IP behind a reverse proxy. The proxy connects to MindSift and passes the visitor's address in
// X-Forwarded-For / X-Real-IP. Those headers are only believed when the connection comes from a trusted
// proxy (TRUSTED_PROXIES), otherwise anyone reaching the port directly could claim any address and dodge
// the login protection.
// private (default) loopback and private networks (Docker, LAN, CGNAT/Tailscale)
// all always believe the headers (old behaviour, switches the check off)
// none never believe them
// CIDR list e.g. "private,203.0.113.0/24"; Cloudflare and other external proxies go here
type clientIPKey struct{}
type clientIPInfo struct {
ip string
// viaProxyWithoutHeader: the connection came from a trusted proxy that did not say who the
// visitor is, so all visitors look the same. Per-IP limits must not apply then.
viaProxyWithoutHeader bool
}
type proxyTrust struct {
all bool
networks []*net.IPNet
}
var privateProxyNets = mustCIDRs("127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7", "100.64.0.0/10")
func parseProxyTrust(spec string) proxyTrust {
spec = strings.ToLower(strings.TrimSpace(spec))
if spec == "" {
spec = "private"
}
var t proxyTrust
for _, part := range strings.Split(spec, ",") {
switch part = strings.TrimSpace(part); part {
case "", "none":
case "all", "*":
t.all = true
case "private":
t.networks = append(t.networks, privateProxyNets...)
default:
if !strings.Contains(part, "/") {
if strings.Contains(part, ":") {
part += "/128"
} else {
part += "/32"
}
}
if _, n, err := net.ParseCIDR(part); err == nil {
t.networks = append(t.networks, n)
} else {
slog.Warn("TRUSTED_PROXIES: Eintrag ignoriert", "entry", part)
}
}
}
return t
}
func (t proxyTrust) trusts(ip net.IP) bool {
if t.all {
return true
}
for _, n := range t.networks {
if n.Contains(ip) {
return true
}
}
return false
}
func peerIP(remoteAddr string) string {
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
return host
}
return remoteAddr
}
// resolveClientIP returns the visitor's address for a request coming from peer.
func (t proxyTrust) resolveClientIP(peer string, h http.Header) clientIPInfo {
peerAddr := net.ParseIP(peer)
if peerAddr == nil || !t.trusts(peerAddr) {
return clientIPInfo{ip: peer}
}
// X-Forwarded-For: client, proxy1, proxy2 – the visitor is the right-most address that is not one of
// our proxies (everything left of it could be made up by the visitor)
if xff := h.Get("X-Forwarded-For"); xff != "" {
var chain []net.IP
for _, part := range strings.Split(xff, ",") {
if ip := net.ParseIP(strings.TrimSpace(part)); ip != nil {
chain = append(chain, ip)
}
}
if len(chain) > 0 {
if t.all {
return clientIPInfo{ip: chain[0].String()} // former behaviour: first address counts
}
for i := len(chain) - 1; i >= 0; i-- {
if !t.trusts(chain[i]) {
return clientIPInfo{ip: chain[i].String()}
}
}
return clientIPInfo{ip: chain[0].String()} // only proxies in the chain: the visitor is in our network
}
}
for _, name := range []string{"X-Real-IP", "True-Client-IP", "CF-Connecting-IP"} {
if ip := net.ParseIP(strings.TrimSpace(h.Get(name))); ip != nil {
return clientIPInfo{ip: ip.String()}
}
}
return clientIPInfo{ip: peer, viaProxyWithoutHeader: !peerAddr.IsLoopback()}
}
var warnProxyHeaderOnce sync.Once
// clientIPMiddleware replaces chi's RealIP: r.RemoteAddr becomes the visitor's address.
func (s *Server) clientIPMiddleware(next http.Handler) http.Handler {
trust := parseProxyTrust(s.cfg.TrustedProxies)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info := trust.resolveClientIP(peerIP(r.RemoteAddr), r.Header)
if info.viaProxyWithoutHeader {
warnProxyHeaderOnce.Do(func() {
slog.Warn("Reverse-Proxy übergibt keine Client-IP (X-Forwarded-For/X-Real-IP fehlt); Login-Sperren pro IP sind für diese Anfragen aus", "proxy", info.ip)
})
}
r.RemoteAddr = info.ip
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), clientIPKey{}, info)))
})
}
func clientIPFrom(r *http.Request) clientIPInfo {
if info, ok := r.Context().Value(clientIPKey{}).(clientIPInfo); ok {
return info
}
return clientIPInfo{ip: peerIP(r.RemoteAddr)}
}
+1
View File
@@ -217,6 +217,7 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
return
}
s.loginGuard.unlockAccount(targetUserID)
if err := s.store.UpdateUserPassword(targetUserID, hash); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
+14
View File
@@ -261,16 +261,30 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
user, err := s.store.GetUserByLogin(identifier)
// Unknown names are counted under the typed login, so locking looks the same as for real accounts
account := "login:" + strings.ToLower(identifier)
if err == nil {
account = user.ID
}
ip := clientIPFrom(r)
if wait := s.loginGuard.lockedFor(account, ip); wait > 0 {
slog.Warn("Login gesperrt nach Fehlversuchen", "login", identifier, "remote_addr", r.RemoteAddr, "wait", wait.Round(time.Second))
writeLoginLocked(w, wait)
return
}
if err != nil {
s.loginGuard.recordFailure(account, ip)
slog.Warn("Login fehlgeschlagen: Benutzer existiert nicht", "login", identifier, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
return
}
if !auth.CheckPassword(req.Password, user.PasswordHash) {
s.loginGuard.recordFailure(account, ip)
slog.Warn("Login fehlgeschlagen: Falsches Passwort", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
return
}
s.loginGuard.recordSuccess(account, ip)
if !user.IsActive {
slog.Warn("Login verweigert: Benutzerkonto ist deaktiviert", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert")
+147
View File
@@ -0,0 +1,147 @@
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))
}
+136
View File
@@ -0,0 +1,136 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/auth"
)
func TestResolveClientIP(t *testing.T) {
h := func(kv ...string) http.Header {
out := http.Header{}
for i := 0; i < len(kv); i += 2 {
out.Set(kv[i], kv[i+1])
}
return out
}
private := parseProxyTrust("private")
cases := []struct {
name string
trust proxyTrust
peer string
hdr http.Header
want string
noHdr bool
}{
{"proxy in docker network passes the visitor", private, "172.18.0.2", h("X-Forwarded-For", "203.0.113.7"), "203.0.113.7", false},
{"spoofed left part of the chain is ignored", private, "172.18.0.2", h("X-Forwarded-For", "1.2.3.4, 203.0.113.7, 10.0.0.5"), "203.0.113.7", false},
{"X-Real-IP from a trusted proxy", private, "127.0.0.1", h("X-Real-IP", "198.51.100.9"), "198.51.100.9", false},
{"direct visitor cannot fake an address", private, "203.0.113.50", h("X-Forwarded-For", "1.2.3.4"), "203.0.113.50", false},
{"proxy without header is flagged", private, "172.18.0.2", h(), "172.18.0.2", true},
{"all: headers always believed", parseProxyTrust("all"), "203.0.113.50", h("X-Forwarded-For", "1.2.3.4, 5.6.7.8"), "1.2.3.4", false},
{"none: headers never believed", parseProxyTrust("none"), "172.18.0.2", h("X-Forwarded-For", "1.2.3.4"), "172.18.0.2", false},
{"external proxy by CIDR", parseProxyTrust("private,198.51.100.0/24"), "198.51.100.1", h("CF-Connecting-IP", "203.0.113.8"), "203.0.113.8", false},
}
for _, c := range cases {
got := c.trust.resolveClientIP(c.peer, c.hdr)
if got.ip != c.want || got.viaProxyWithoutHeader != c.noHdr {
t.Errorf("%s: got %+v, want %s (noHeader=%v)", c.name, got, c.want, c.noHdr)
}
}
}
func TestLoginGuardBuckets(t *testing.T) {
g := newLoginGuard(true)
now := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)
g.now = func() time.Time { return now }
attacker := clientIPInfo{ip: "203.0.113.66"}
owner := clientIPInfo{ip: "198.51.100.10"}
for i := 0; i < loginLimitAccountIP; i++ {
g.recordFailure("usr_admin", attacker)
}
if g.lockedFor("usr_admin", attacker) != loginLockWindow {
t.Fatalf("3 failures from one address must lock that address out of the account")
}
if g.lockedFor("usr_admin", owner) != 0 {
t.Fatalf("the owner logging in from elsewhere must not be locked out")
}
// Spread over many addresses: the account itself locks at 20
for i := 0; i < loginLimitAccount; i++ {
g.recordFailure("usr_admin", clientIPInfo{ip: "192.0.2." + string(rune('0'+i%10)) + string(rune('0'+i/10))})
}
if g.lockedFor("usr_admin", owner) == 0 {
t.Fatalf("20 failures across addresses must lock the account")
}
g.unlockAccount("usr_admin")
if g.lockedFor("usr_admin", owner) != 0 || g.lockedFor("usr_admin", attacker) != 0 {
t.Fatalf("unlockAccount must lift account and account+IP locks")
}
// The lock ends by itself
for i := 0; i < loginLimitAccountIP; i++ {
g.recordFailure("usr_ben", attacker)
}
now = now.Add(loginLockWindow + time.Second)
if g.lockedFor("usr_ben", attacker) != 0 {
t.Fatalf("lock must expire after the window")
}
// Without a known client IP only the account bucket counts
proxied := clientIPInfo{ip: "172.18.0.2", viaProxyWithoutHeader: true}
for i := 0; i < 10; i++ {
g.recordFailure("usr_carla", proxied)
}
if g.lockedFor("usr_carla", proxied) != 0 {
t.Fatalf("per-IP limits must not apply when the proxy hides the client IP")
}
off := newLoginGuard(false)
for i := 0; i < 50; i++ {
off.recordFailure("usr_admin", attacker)
}
if off.lockedFor("usr_admin", attacker) != 0 {
t.Fatalf("LOGIN_PROTECTION=false must never lock")
}
}
func TestLoginLockedAfterThreeFailures(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
_, _ = store.CreateUser("lock@example.com", mustHash(t, "richtig123"), "Lock", "member")
login := func(pw, from string) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(`{"login":"lock@example.com","password":"`+pw+`"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = from + ":4711"
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
for i := 0; i < 3; i++ {
if rec := login("falsch", "203.0.113.66"); rec.Code != http.StatusUnauthorized {
t.Fatalf("attempt %d: want 401, got %d", i+1, rec.Code)
}
}
rec := login("richtig123", "203.0.113.66")
if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") == "" || !strings.Contains(rec.Body.String(), "15 Minuten") {
t.Fatalf("locked login: want 429 with Retry-After, got %d %s", rec.Code, rec.Body.String())
}
if rec := login("richtig123", "198.51.100.10"); rec.Code != http.StatusOK {
t.Fatalf("owner from another address must still log in, got %d", rec.Code)
}
}
func mustHash(t *testing.T, pw string) string {
t.Helper()
h, err := auth.HashPassword(pw)
if err != nil {
t.Fatal(err)
}
return h
}
+1
View File
@@ -86,6 +86,7 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
return
}
s.loginGuard.unlockAccount(userID)
slog.Info("Passwort per E-Mail-Link zurückgesetzt", "user_id", userID)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
+4 -2
View File
@@ -29,7 +29,8 @@ type Server struct {
leases *collab.LeaseManager // block leases and open sessions of notes
notifier *collab.Notifier // wakes long-poll requests on note changes
demo demoState // periodic reset in demo mode (demo.go)
demo demoState // periodic reset in demo mode (demo.go)
loginGuard *loginGuard // brute-force protection (loginguard.go)
}
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
@@ -41,6 +42,7 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
leases: collab.NewLeaseManager(nil),
notifier: collab.NewNotifier(),
}
srv.loginGuard = newLoginGuard(!cfg.DisableLoginProtection)
wsHub.SetRoomAuthorizer(srv.canJoinRoom)
// Next occurrences of repeating tasks appear live on every connected client
store.OnTaskCreated = func(th *domain.Thought) {
@@ -74,7 +76,7 @@ func (s *Server) taskAudience(th *domain.Thought) []string {
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(s.clientIPMiddleware)
r.Use(s.demoResetGate)
r.Use(middleware.Recoverer)
r.Use(s.apiLoggingMiddleware)