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>
137 lines
4.9 KiB
Go
137 lines
4.9 KiB
Go
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
|
|
}
|