mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
8e8541a5d8
The login limiter counted failures per (address, username) only after the check finished, so attempts sent together all passed before the first failure was recorded, and one address could own every record of the bounded table and push out the one holding its own failures. An admitted attempt now holds a slot until it reports back, and an address is blocked as a whole after five times the per-username limit spread over any names, which also caps the records one peer can own.
163 lines
5.5 KiB
Go
163 lines
5.5 KiB
Go
package controller
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestLoginLimiterBoundsMemoryUnderUsernameFlood(t *testing.T) {
|
|
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
|
|
for i := range loginLimitMaxRecords + 100 {
|
|
limiter.registerFailure("1.2.3.4", "user-"+strconv.Itoa(i))
|
|
}
|
|
|
|
limiter.mu.Lock()
|
|
n := len(limiter.attempts)
|
|
limiter.mu.Unlock()
|
|
|
|
if n > loginLimitMaxRecords {
|
|
t.Fatalf("attempts map grew to %d, exceeding the %d ceiling under a username flood", n, loginLimitMaxRecords)
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterEvictionSparesActiveBlocks(t *testing.T) {
|
|
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
|
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
|
|
limiter.now = func() time.Time { return now }
|
|
|
|
limiter.mu.Lock()
|
|
for i := range loginLimitMaxRecords - 1 {
|
|
limiter.attempts["victim-"+strconv.Itoa(i)] = &loginLimitRecord{blockedUntil: now.Add(10 * time.Minute)}
|
|
}
|
|
limiter.attempts["filler"] = &loginLimitRecord{failures: []time.Time{now}}
|
|
limiter.mu.Unlock()
|
|
|
|
if _, blocked := limiter.registerFailure("9.9.9.9", "newcomer"); blocked {
|
|
t.Fatal("the eviction-triggering failure itself should not be blocked yet")
|
|
}
|
|
|
|
limiter.mu.Lock()
|
|
defer limiter.mu.Unlock()
|
|
survivors := 0
|
|
for key, record := range limiter.attempts {
|
|
if strings.HasPrefix(key, "victim-") && now.Before(record.blockedUntil) {
|
|
survivors++
|
|
}
|
|
}
|
|
if survivors != loginLimitMaxRecords-1 {
|
|
t.Fatalf("eviction under a full map dropped an actively-blocked record: %d/%d victims survived", survivors, loginLimitMaxRecords-1)
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterBlocksAfterConfiguredFailures(t *testing.T) {
|
|
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
|
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
|
|
limiter.now = func() time.Time { return now }
|
|
|
|
for i := range 4 {
|
|
if _, blocked := limiter.registerFailure("192.0.2.10", "Admin"); blocked {
|
|
t.Fatalf("failure %d should not block yet", i+1)
|
|
}
|
|
if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
|
|
t.Fatalf("failure %d should still allow login attempts", i+1)
|
|
}
|
|
}
|
|
|
|
blockedUntil, blocked := limiter.registerFailure("192.0.2.10", "ADMIN")
|
|
if !blocked {
|
|
t.Fatal("fifth failure should start cooldown")
|
|
}
|
|
if want := now.Add(15 * time.Minute); !blockedUntil.Equal(want) {
|
|
t.Fatalf("blocked until %s, want %s", blockedUntil, want)
|
|
}
|
|
if _, ok := limiter.allow("192.0.2.10", "admin"); ok {
|
|
t.Fatal("login should be blocked during cooldown")
|
|
}
|
|
|
|
now = blockedUntil
|
|
if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
|
|
t.Fatal("login should be allowed after cooldown")
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterPrunesOldFailuresAndResetsOnSuccess(t *testing.T) {
|
|
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
|
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
|
|
limiter.now = func() time.Time { return now }
|
|
|
|
for range 4 {
|
|
limiter.registerFailure("192.0.2.10", "admin")
|
|
}
|
|
now = now.Add(6 * time.Minute)
|
|
if _, blocked := limiter.registerFailure("192.0.2.10", "admin"); blocked {
|
|
t.Fatal("old failures should be pruned outside the rolling window")
|
|
}
|
|
|
|
limiter.registerSuccess("192.0.2.10", "admin")
|
|
for i := range 4 {
|
|
if _, blocked := limiter.registerFailure("192.0.2.10", "admin"); blocked {
|
|
t.Fatalf("success should reset previous failures; failure %d blocked", i+1)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoginLimiterSeparatesIPAndUsername(t *testing.T) {
|
|
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
|
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
|
|
limiter.now = func() time.Time { return now }
|
|
|
|
for range 5 {
|
|
limiter.registerFailure("192.0.2.10", "admin")
|
|
}
|
|
if _, ok := limiter.allow("192.0.2.11", "admin"); !ok {
|
|
t.Fatal("different IP should not be blocked")
|
|
}
|
|
if _, ok := limiter.allow("192.0.2.10", "other-admin"); !ok {
|
|
t.Fatal("different username should not be blocked")
|
|
}
|
|
}
|
|
|
|
// Without a per-address bound, one address can fill the table with its own blocked
|
|
// records and evict the record holding its admin failures, resetting the count.
|
|
func TestLoginLimiterBlocksAnAddressSpreadingFailuresOverUsernames(t *testing.T) {
|
|
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
|
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
|
|
limiter.now = func() time.Time { return now }
|
|
|
|
for i := range 5 * loginLimitIPFactor {
|
|
if _, ok := limiter.allow("192.0.2.10", "decoy-"+strconv.Itoa(i)); !ok {
|
|
t.Fatalf("attempt %d refused before the address limit", i+1)
|
|
}
|
|
limiter.registerFailure("192.0.2.10", "decoy-"+strconv.Itoa(i))
|
|
}
|
|
if _, ok := limiter.allow("192.0.2.10", "admin"); ok {
|
|
t.Fatal("an address past its failure budget may still try the admin account")
|
|
}
|
|
if _, ok := limiter.allow("192.0.2.11", "admin"); !ok {
|
|
t.Fatal("another address was blocked too")
|
|
}
|
|
}
|
|
|
|
// The count must cover attempts admitted and still being checked: bcrypt is slow, and
|
|
// a burst sent at once would otherwise all pass before the first failure is recorded.
|
|
func TestLoginLimiterCountsAttemptsStillBeingChecked(t *testing.T) {
|
|
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
|
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
|
|
limiter.now = func() time.Time { return now }
|
|
|
|
for i := range 5 {
|
|
if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
|
|
t.Fatalf("attempt %d of a burst refused", i+1)
|
|
}
|
|
}
|
|
if _, ok := limiter.allow("192.0.2.10", "admin"); ok {
|
|
t.Fatal("a sixth concurrent attempt was admitted")
|
|
}
|
|
now = now.Add(20 * time.Minute)
|
|
if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
|
|
t.Fatal("attempts that never reported back still block after the window and cooldown")
|
|
}
|
|
}
|