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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user