login_limiter_test.go 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  1. package controller
  2. import (
  3. "strconv"
  4. "testing"
  5. "time"
  6. )
  7. // An unauthenticated attacker can flood /login with fresh usernames, each of
  8. // which seeds a record keyed on that username. The attempts map must stay
  9. // bounded rather than growing until the process OOMs.
  10. func TestLoginLimiterBoundsMemoryUnderUsernameFlood(t *testing.T) {
  11. limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
  12. for i := 0; i < loginLimitMaxRecords+100; i++ {
  13. limiter.registerFailure("1.2.3.4", "user-"+strconv.Itoa(i))
  14. }
  15. limiter.mu.Lock()
  16. n := len(limiter.attempts)
  17. limiter.mu.Unlock()
  18. if n > loginLimitMaxRecords {
  19. t.Fatalf("attempts map grew to %d, exceeding the %d ceiling under a username flood", n, loginLimitMaxRecords)
  20. }
  21. }
  22. func TestLoginLimiterBlocksAfterConfiguredFailures(t *testing.T) {
  23. now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
  24. limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
  25. limiter.now = func() time.Time { return now }
  26. for i := range 4 {
  27. if _, blocked := limiter.registerFailure("192.0.2.10", "Admin"); blocked {
  28. t.Fatalf("failure %d should not block yet", i+1)
  29. }
  30. if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
  31. t.Fatalf("failure %d should still allow login attempts", i+1)
  32. }
  33. }
  34. blockedUntil, blocked := limiter.registerFailure("192.0.2.10", "ADMIN")
  35. if !blocked {
  36. t.Fatal("fifth failure should start cooldown")
  37. }
  38. if want := now.Add(15 * time.Minute); !blockedUntil.Equal(want) {
  39. t.Fatalf("blocked until %s, want %s", blockedUntil, want)
  40. }
  41. if _, ok := limiter.allow("192.0.2.10", "admin"); ok {
  42. t.Fatal("login should be blocked during cooldown")
  43. }
  44. now = blockedUntil
  45. if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
  46. t.Fatal("login should be allowed after cooldown")
  47. }
  48. }
  49. func TestLoginLimiterPrunesOldFailuresAndResetsOnSuccess(t *testing.T) {
  50. now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
  51. limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
  52. limiter.now = func() time.Time { return now }
  53. for range 4 {
  54. limiter.registerFailure("192.0.2.10", "admin")
  55. }
  56. now = now.Add(6 * time.Minute)
  57. if _, blocked := limiter.registerFailure("192.0.2.10", "admin"); blocked {
  58. t.Fatal("old failures should be pruned outside the rolling window")
  59. }
  60. limiter.registerSuccess("192.0.2.10", "admin")
  61. for i := range 4 {
  62. if _, blocked := limiter.registerFailure("192.0.2.10", "admin"); blocked {
  63. t.Fatalf("success should reset previous failures; failure %d blocked", i+1)
  64. }
  65. }
  66. }
  67. func TestLoginLimiterSeparatesIPAndUsername(t *testing.T) {
  68. now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
  69. limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
  70. limiter.now = func() time.Time { return now }
  71. for range 5 {
  72. limiter.registerFailure("192.0.2.10", "admin")
  73. }
  74. if _, ok := limiter.allow("192.0.2.11", "admin"); !ok {
  75. t.Fatal("different IP should not be blocked")
  76. }
  77. if _, ok := limiter.allow("192.0.2.10", "other-admin"); !ok {
  78. t.Fatal("different username should not be blocked")
  79. }
  80. }