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
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:
1 parent
6b9eb62caf
commit
11c14fa9ff
12 files changed
+503
-30
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user