Files
sm-user-plugin/login_client_ip_test.go
Jakub Zych b6f8055c56 fix: /login throttles failed logins per client address behind trusted proxies (surf.ClientIP), like /register
Behind a reverse proxy every visitor had the proxy's RemoteAddr, so one client's failed logins locked the account for all. The throttle key is now surf.ClientIP with http.trusted_proxies, the expression /register uses; X-Forwarded-For from an untrusted peer is still ignored. RemoteIP is unchanged.
2026-10-22 22:53:29 +02:00

107 lines
4.1 KiB
Go

package user
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/compass"
)
// TestLoginThrottleClientIP pins the /login throttle key: the client address
// surf.ClientIP derives with http.trusted_proxies, as /register and the
// user-api rate limit use, and never an X-Forwarded-For header from an
// untrusted peer.
func TestLoginThrottleClientIP(t *testing.T) {
t.Run("behind a trusted proxy", func(t *testing.T) {
e := newAdminEnv(t, func(cfg *compass.Config) {
if err := cfg.Set("http.trusted_proxies", []string{"10.0.0.0/8"}); err != nil {
t.Fatal(err)
}
})
user := e.seedUser(t, "throttle-proxy", nil)
const proxy = "10.0.0.2:5000"
for i := range 5 {
rec := e.loginFrom(t, proxy, "203.0.113.10", user.Email, "wrong-password")
if rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
t.Fatalf("failed login %d = %d %s", i+1, rec.Code, rec.Body.String())
}
}
assertThrottleRow(t, e, user, "203.0.113.10")
if rec := e.loginFrom(t, proxy, "203.0.113.10", user.Email, "correct-horse"); rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
t.Fatalf("suspended client login = %d %s", rec.Code, rec.Body.String())
}
rec := e.loginFrom(t, proxy, "203.0.113.20", user.Email, "correct-horse")
var body struct {
Token string `json:"token"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); rec.Code != http.StatusOK || err != nil || body.Token == "" {
t.Fatalf("other client behind the proxy = %d %s", rec.Code, rec.Body.String())
}
})
t.Run("untrusted X-Forwarded-For is ignored", func(t *testing.T) {
e := newAdminEnv(t)
user := e.seedUser(t, "throttle-direct", nil)
const peer = "192.0.2.50:4000"
for _, forged := range []string{"203.0.113.1", "203.0.113.2", "203.0.113.3", "203.0.113.4", "203.0.113.5"} {
if rec := e.loginFrom(t, peer, forged, user.Email, "wrong-password"); rec.Code != http.StatusUnauthorized {
t.Fatalf("failed login with X-Forwarded-For %s = %d %s", forged, rec.Code, rec.Body.String())
}
}
assertThrottleRow(t, e, user, "192.0.2.50")
if rec := e.loginFrom(t, peer, "203.0.113.99", user.Email, "correct-horse"); rec.Code != http.StatusUnauthorized || rec.Body.String() != invalidLoginBody {
t.Fatalf("forged X-Forwarded-For escaped the suspension: %d %s", rec.Code, rec.Body.String())
}
if rec := e.loginFrom(t, "192.0.2.51:4000", "", user.Email, "correct-horse"); rec.Code != http.StatusOK {
t.Fatalf("login from another address = %d %s", rec.Code, rec.Body.String())
}
})
}
// assertThrottleRow: the account has exactly one user_throttle row, keyed on
// ip, with five attempts and suspended.
func assertThrottleRow(t *testing.T, e *adminEnv, user *models.User, ip string) {
t.Helper()
var rows []models.Throttle
if err := e.gdb.Where("user_id = ?", user.ID).Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("throttle rows = %d, want 1: %+v", len(rows), rows)
}
row := rows[0]
got := "<nil>"
if row.IPAddress != nil {
got = *row.IPAddress
}
if got != ip || row.Attempts != 5 || !row.IsSuspended {
t.Fatalf("throttle row ip=%s attempts=%d suspended=%v, want ip=%s attempts=5 suspended", got, row.Attempts, row.IsSuspended, ip)
}
}
// loginFrom posts a login from a fixed connection address, with an
// X-Forwarded-For header when forwardedFor is set. send cannot model a
// reverse proxy: it gives every request its own address.
func (e *adminEnv) loginFrom(t *testing.T, remoteAddr, forwardedFor, email, password string) *httptest.ResponseRecorder {
t.Helper()
raw, err := json.Marshal(map[string]string{"email": email, "password": password})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/login", bytes.NewReader(raw))
req.Host = "example.com"
req.RemoteAddr = remoteAddr
req.Header.Set("Accept-Language", "en")
req.Header.Set("Content-Type", "application/json")
if forwardedFor != "" {
req.Header.Set("X-Forwarded-For", forwardedFor)
}
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}