feat(backend): per-IP login rate limit with proxy-aware client IP

Adds clientIP() (reads the rightmost X-Forwarded-For hop via
Header.Values, since Traefik appends the peer address it actually
observed and the leftmost entries are client-controlled) and
loginLimiter, an in-memory per-IP counter that blocks after
loginMaxFailures within loginWindow. No routes wire these up yet —
that lands in Task 5.
This commit is contained in:
2026-07-25 22:52:22 +07:00
parent 7d0eaaef02
commit 6030a985fa
2 changed files with 186 additions and 0 deletions
+94
View File
@@ -128,3 +128,97 @@ func TestClearSessionCookie(t *testing.T) {
t.Fatalf("cleared cookie MaxAge = %d, want negative", cookies[0].MaxAge)
}
}
func TestClientIP(t *testing.T) {
cases := []struct {
name string
remoteAddr string
xff []string
want string
}{
{"no header falls back to remote addr", "203.0.113.9:5555", nil, "203.0.113.9"},
{"single proxy hop", "10.0.0.1:5555", []string{"203.0.113.9"}, "203.0.113.9"},
{
// The client sent "1.2.3.4" itself; Traefik appended the address it
// actually saw. Only the rightmost entry is trustworthy.
name: "spoofed left entry is ignored",
remoteAddr: "10.0.0.1:5555",
xff: []string{"1.2.3.4, 203.0.113.9"},
want: "203.0.113.9",
},
{
name: "spoofed separate header line is ignored",
remoteAddr: "10.0.0.1:5555",
xff: []string{"1.2.3.4", "203.0.113.9"},
want: "203.0.113.9",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/login", nil)
r.RemoteAddr = tc.remoteAddr
for _, v := range tc.xff {
r.Header.Add("X-Forwarded-For", v)
}
if got := clientIP(r); got != tc.want {
t.Fatalf("clientIP() = %q, want %q", got, tc.want)
}
})
}
}
func TestLoginLimiterBlocksAfterMaxFailures(t *testing.T) {
l := newLoginLimiter()
now := time.Now()
for i := 0; i < loginMaxFailures; i++ {
if wait := l.retryAfter("1.2.3.4", now); wait != 0 {
t.Fatalf("blocked after %d failures, want block only after %d", i, loginMaxFailures)
}
l.fail("1.2.3.4", now)
}
wait := l.retryAfter("1.2.3.4", now)
if wait <= 0 {
t.Fatalf("retryAfter = %v after %d failures, want > 0", wait, loginMaxFailures)
}
if wait > loginWindow {
t.Fatalf("retryAfter = %v, want <= %v", wait, loginWindow)
}
}
func TestLoginLimiterWindowExpires(t *testing.T) {
l := newLoginLimiter()
start := time.Now()
for i := 0; i < loginMaxFailures; i++ {
l.fail("1.2.3.4", start)
}
if l.retryAfter("1.2.3.4", start) == 0 {
t.Fatal("expected block immediately after the failures")
}
later := start.Add(loginWindow + time.Second)
if wait := l.retryAfter("1.2.3.4", later); wait != 0 {
t.Fatalf("retryAfter = %v once the window passed, want 0", wait)
}
}
func TestLoginLimiterResetClearsCounter(t *testing.T) {
l := newLoginLimiter()
now := time.Now()
for i := 0; i < loginMaxFailures; i++ {
l.fail("1.2.3.4", now)
}
l.reset("1.2.3.4")
if wait := l.retryAfter("1.2.3.4", now); wait != 0 {
t.Fatalf("retryAfter = %v after reset, want 0", wait)
}
}
func TestLoginLimiterIsPerIP(t *testing.T) {
l := newLoginLimiter()
now := time.Now()
for i := 0; i < loginMaxFailures; i++ {
l.fail("1.2.3.4", now)
}
if wait := l.retryAfter("5.6.7.8", now); wait != 0 {
t.Fatalf("retryAfter for a different IP = %v, want 0", wait)
}
}