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 := "" 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 }