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.
107 lines
4.1 KiB
Go
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
|
|
}
|