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

+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
}