Harden RateLimit against X-Forwarded-For spoofing

Key on RemoteAddr by default; honor X-Forwarded-For only when the peer is
a configured trusted proxy (WithTrustedProxies), walking right-to-left to
the first untrusted hop. This closes a trivial rate-limit bypass and the
matching unbounded-bucket DoS via spoofed headers. Add WithMaxKeys with
opportunistic eviction of idle (fully-refilled) buckets to bound memory.
Drop the hand-rolled indexOf in favor of stdlib.
This commit is contained in:
2026-05-23 13:47:08 +03:00
parent b6350185d9
commit 2d4a06e715
3 changed files with 278 additions and 66 deletions

View File

@@ -83,28 +83,59 @@ func TestRateLimit(t *testing.T) {
}
})
t.Run("uses X-Forwarded-For", func(t *testing.T) {
t.Run("ignores X-Forwarded-For without trusted proxies", func(t *testing.T) {
// By default the limiter keys on RemoteAddr only. A spoofed,
// per-request X-Forwarded-For must not let a single peer bypass the
// limit by minting a fresh bucket each time.
mw := server.RateLimit(
server.WithRate(1),
server.WithBurst(1),
)(okHandler)
// Exhaust limit for 10.0.0.1.
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Forwarded-For", "10.0.0.1, 192.168.1.1")
req.RemoteAddr = "192.168.1.1:1234"
mw.ServeHTTP(w, req)
send := func(xff string) int {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Forwarded-For", xff)
req.RemoteAddr = "192.168.1.1:1234"
mw.ServeHTTP(w, req)
return w.Code
}
// Same forwarded IP should be rate limited.
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Forwarded-For", "10.0.0.1, 192.168.1.1")
req.RemoteAddr = "192.168.1.1:1234"
mw.ServeHTTP(w, req)
if code := send("10.0.0.1"); code != http.StatusOK {
t.Fatalf("first request: got %d, want %d", code, http.StatusOK)
}
// Different spoofed XFF, same peer — must still be limited.
if code := send("10.0.0.2"); code != http.StatusTooManyRequests {
t.Fatalf("spoofed XFF bypassed limit: got %d, want %d", code, http.StatusTooManyRequests)
}
})
if w.Code != http.StatusTooManyRequests {
t.Fatalf("got status %d, want %d", w.Code, http.StatusTooManyRequests)
t.Run("honors X-Forwarded-For behind trusted proxy", func(t *testing.T) {
mw := server.RateLimit(
server.WithRate(1),
server.WithBurst(1),
server.WithTrustedProxies("192.168.0.0/16"),
)(okHandler)
send := func(xff string) int {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Forwarded-For", xff)
req.RemoteAddr = "192.168.1.1:1234" // trusted proxy
mw.ServeHTTP(w, req)
return w.Code
}
// Real client 10.0.0.1 (left-most), proxy hop 192.168.1.1 (right-most).
if code := send("10.0.0.1, 192.168.1.1"); code != http.StatusOK {
t.Fatalf("first request: got %d, want %d", code, http.StatusOK)
}
if code := send("10.0.0.1, 192.168.1.1"); code != http.StatusTooManyRequests {
t.Fatalf("same client not limited: got %d, want %d", code, http.StatusTooManyRequests)
}
// A different real client through the same proxy is independent.
if code := send("10.0.0.2, 192.168.1.1"); code != http.StatusOK {
t.Fatalf("different client should be allowed: got %d, want %d", code, http.StatusOK)
}
})