Files
upstream-centrifugo/internal/middleware/auththrottle_test.go
upstream-mirror b352a9a30d
Some checks failed
test / Lint (push) Failing after 3s
test / Test with Go 1.26.8 (push) Failing after 2s
snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:17:21 +05:00

149 lines
5.7 KiB
Go

package middleware
import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// goodPassword is accepted by the test handler; anything else returns 400.
const goodPassword = "correct"
func throttleTestHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("pw") == goodPassword {
w.WriteHeader(http.StatusOK)
return
}
http.Error(w, "bad", http.StatusBadRequest)
})
}
func doReq(h http.Handler, ip, pw string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, "/auth?pw="+pw, nil)
r.RemoteAddr = "10.0.0.1:12345" // trusted local proxy peer, so X-Real-IP is honored.
r.Header.Set("X-Real-IP", ip)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func TestAuthThrottle_LimitsFailuresPerIP(t *testing.T) {
h := NewAuthThrottle(3, time.Minute, nil).Middleware(throttleTestHandler())
// First 3 wrong attempts from an IP reach the handler and return its status.
for i := 0; i < 3; i++ {
require.Equal(t, http.StatusBadRequest, doReq(h, "1.1.1.1", "wrong").Code, "attempt %d", i)
}
// The 4th is rejected before the handler runs, with a Retry-After hint.
resp := doReq(h, "1.1.1.1", "wrong")
require.Equal(t, http.StatusTooManyRequests, resp.Code)
require.Equal(t, strconv.Itoa(60), resp.Header().Get("Retry-After"))
// Even a correct password from the throttled IP is rejected with 429, so the
// response does not reveal that the credentials were valid.
require.Equal(t, http.StatusTooManyRequests, doReq(h, "1.1.1.1", goodPassword).Code)
}
func TestAuthThrottle_DoesNotBlockOtherIPsOrSuccess(t *testing.T) {
h := NewAuthThrottle(3, time.Minute, nil).Middleware(throttleTestHandler())
// Exhaust the limit for the attacker's IP.
for i := 0; i < 5; i++ {
_ = doReq(h, "9.9.9.9", "wrong")
}
require.Equal(t, http.StatusTooManyRequests, doReq(h, "9.9.9.9", "wrong").Code)
// A legitimate admin on a different IP still logs in while the attack runs.
require.Equal(t, http.StatusOK, doReq(h, "2.2.2.2", goodPassword).Code)
// Success never accrues against the limit: many valid logins stay allowed.
for i := 0; i < 20; i++ {
require.Equal(t, http.StatusOK, doReq(h, "3.3.3.3", goodPassword).Code, "login %d", i)
}
}
func TestAuthThrottle_PublicPeerCannotSpoofHeader(t *testing.T) {
h := NewAuthThrottle(3, time.Minute, nil).Middleware(throttleTestHandler())
// A direct public client rotating X-Real-IP cannot evade the limit: the header
// is untrusted (peer is not a local proxy), so every attempt keys on the real
// socket peer and the IP is throttled after the limit regardless of the header.
req := func(spoofedIP string) int {
r := httptest.NewRequest(http.MethodPost, "/auth?pw=wrong", nil)
r.RemoteAddr = "203.0.113.5:9999" // public peer.
r.Header.Set("X-Real-IP", spoofedIP)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Code
}
require.Equal(t, http.StatusBadRequest, req("1.1.1.1"))
require.Equal(t, http.StatusBadRequest, req("2.2.2.2"))
require.Equal(t, http.StatusBadRequest, req("3.3.3.3"))
require.Equal(t, http.StatusTooManyRequests, req("4.4.4.4"))
}
func TestAuthThrottle_WindowReset(t *testing.T) {
h := NewAuthThrottle(2, 50*time.Millisecond, nil).Middleware(throttleTestHandler())
require.Equal(t, http.StatusBadRequest, doReq(h, "1.2.3.4", "wrong").Code)
require.Equal(t, http.StatusBadRequest, doReq(h, "1.2.3.4", "wrong").Code)
require.Equal(t, http.StatusTooManyRequests, doReq(h, "1.2.3.4", "wrong").Code)
// After the window elapses the count resets and the IP may try again.
time.Sleep(70 * time.Millisecond)
require.Equal(t, http.StatusBadRequest, doReq(h, "1.2.3.4", "wrong").Code)
}
func TestAuthThrottle_MapBounded(t *testing.T) {
tr := NewAuthThrottle(1, time.Minute, nil)
// Track an IP, then flood the map with unique addresses past its capacity.
tr.recordFailure("10.0.0.1")
for i := 0; i < authThrottleMapCap+100; i++ {
tr.recordFailure("10.9." + strconv.Itoa(i/256) + "." + strconv.Itoa(i%256))
}
// The already-tracked IP keeps counting even though the map is now full.
tr.recordFailure("10.0.0.1")
tr.mu.Lock()
size := len(tr.failures)
pretrackedCount := tr.failures["10.0.0.1"]
tr.mu.Unlock()
require.LessOrEqual(t, size, authThrottleMapCap, "map must stay bounded")
require.Equal(t, 2, pretrackedCount, "already-tracked IP keeps counting")
}
func TestClientIP(t *testing.T) {
newReq := func(realIP, xff, remote string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/", nil)
r.RemoteAddr = remote
if realIP != "" {
r.Header.Set("X-Real-IP", realIP)
}
if xff != "" {
r.Header.Set("X-Forwarded-For", xff)
}
return r
}
// Trusted local proxy peer (private / loopback): forwarded headers are used.
require.Equal(t, "5.5.5.5", clientIP(newReq("5.5.5.5", "1.1.1.1", "10.0.0.9:1")))
require.Equal(t, "1.1.1.1", clientIP(newReq("", "1.1.1.1, 2.2.2.2", "10.0.0.9:1")))
require.Equal(t, "8.8.8.8", clientIP(newReq("8.8.8.8", "", "127.0.0.1:1")))
// Direct public peer: forwarded headers are client-controlled, so ignored -
// the socket peer is used and a spoofed header cannot change the key.
require.Equal(t, "9.9.9.9", clientIP(newReq("", "", "9.9.9.9:12345")))
require.Equal(t, "9.9.9.9", clientIP(newReq("1.2.3.4", "1.2.3.4", "9.9.9.9:12345")))
// Behind a trusted proxy, a non-IP header value falls back to the peer, so an
// attacker cannot inflate map keys or fragment the keyspace with junk.
require.Equal(t, "10.0.0.9", clientIP(newReq(strings.Repeat("x", 5000), "", "10.0.0.9:1")))
require.Equal(t, "10.0.0.9", clientIP(newReq("", "not-an-ip", "10.0.0.9:1")))
require.Equal(t, "raw-addr", clientIP(newReq("", "", "raw-addr")))
}