Skip to content

Commit 18a4189

Browse files
committed
Throttle failed admin password attempts per client IP
The admin password auth endpoint (POST /admin/auth) had no rate limiting, so an attacker with network access could brute-force admin.password at full speed. See GHSA-8w47-3f5r-h9xm. Add a small, dependency-free AuthThrottle middleware that limits failed attempts per client IP within a time window (10 per minute), and wrap the auth route with it. Properties that matter for an auth endpoint: - Only failures count and the per-IP limit is checked before the password is compared, so a caller with valid credentials is not throttled - including while another source is attacking - and the response does not reveal whether a guess was correct once the limit is hit. - Each source IP is tracked independently, with the map bounded and reset per window to keep memory in check. - Forwarded headers (X-Real-IP/X-Forwarded-For) are only trusted when the socket peer is a private/loopback address (a local proxy or load balancer), so a direct client cannot spoof them to evade throttling or lock out another IP; header values are validated as IPs. This is best-effort, in-process defense in depth: the primary protection of the admin endpoint must be done at the infrastructure level (firewall rules, private network, authenticating reverse proxy), as noted at the route registration. The middleware is reusable for other endpoints via NewAuthThrottle and its Middleware method.
1 parent 558c7dd commit 18a4189

4 files changed

Lines changed: 337 additions & 1 deletion

File tree

internal/admin/handlers.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"net/http"
66
"strings"
7+
"time"
78

89
"github.com/centrifugal/centrifugo/v6/internal/api"
910
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
@@ -35,7 +36,18 @@ func NewHandler(n *centrifuge.Node, apiExecutor *api.Executor, c Config) *Handle
3536
mux := http.NewServeMux()
3637
prefix := strings.TrimRight(h.config.HandlerPrefix, "/")
3738
mux.Handle(prefix+"/admin/init", http.HandlerFunc(h.initHandler))
38-
mux.Handle(prefix+"/admin/auth", middleware.Post(http.HandlerFunc(h.authHandler)))
39+
// Throttle repeated failed password attempts per client IP to slow brute-force
40+
// of admin.password. Only failures count and each IP is tracked independently,
41+
// so a valid login is never throttled - including while another source attacks.
42+
//
43+
// This is best-effort, in-process protection only. The admin auth endpoint is
44+
// intended to be reachable by trusted operators, and Centrifugo recommends
45+
// restricting access to it at the infrastructure level (firewall rules, a
46+
// private network, an authenticating reverse proxy, etc.). The throttle here
47+
// raises the bar for brute-force but is not a substitute for that: the primary
48+
// protection of the admin endpoint must be done at the infrastructure level.
49+
authThrottle := middleware.NewAuthThrottle(10, time.Minute, nil)
50+
mux.Handle(prefix+"/admin/auth", middleware.Post(authThrottle.Middleware(http.HandlerFunc(h.authHandler))))
3951
mux.Handle(prefix+"/admin/api", middleware.Post(h.adminSecureTokenAuth(api.NewHandler(n, apiExecutor, api.Config{}).OldRoute())))
4052

4153
webPrefix := prefix + "/"

internal/admin/handlers_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,38 @@ func TestAuthHandler_InvalidPassword(t *testing.T) {
106106
require.Equal(t, http.StatusBadRequest, resp.Code)
107107
}
108108

109+
// TestAuthHandler_Throttled ensures the /admin/auth route throttles repeated
110+
// failed password attempts per client IP, without blocking a valid login from
111+
// another IP while an attacker is brute-forcing.
112+
func TestAuthHandler_Throttled(t *testing.T) {
113+
node := &centrifuge.Node{}
114+
cfg := Config{Password: "test-password", Secret: "test-secret"}
115+
handler := NewHandler(node, nil, cfg)
116+
117+
post := func(ip, password string) int {
118+
form := url.Values{}
119+
form.Add("password", password)
120+
req := httptest.NewRequest("POST", "/admin/auth", strings.NewReader(form.Encode()))
121+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
122+
req.RemoteAddr = "10.0.0.1:12345" // trusted local proxy peer, so X-Real-IP is honored.
123+
req.Header.Set("X-Real-IP", ip)
124+
resp := httptest.NewRecorder()
125+
handler.ServeHTTP(resp, req)
126+
return resp.Code
127+
}
128+
129+
// The default limit is 10 failures per IP per window.
130+
for i := 0; i < 10; i++ {
131+
require.Equal(t, http.StatusBadRequest, post("6.6.6.6", "wrong"), "attempt %d", i)
132+
}
133+
require.Equal(t, http.StatusTooManyRequests, post("6.6.6.6", "wrong"))
134+
// The attacker cannot tell a correct guess from a throttled one.
135+
require.Equal(t, http.StatusTooManyRequests, post("6.6.6.6", "test-password"))
136+
137+
// A valid login from a different IP still succeeds during the attack.
138+
require.Equal(t, http.StatusOK, post("7.7.7.7", "test-password"))
139+
}
140+
109141
// TestAdminSecureTokenAuth_InsecureMode tests adminSecureTokenAuth allows request in insecure mode.
110142
func TestAdminSecureTokenAuth_InsecureMode(t *testing.T) {
111143
config := Config{Insecure: true}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package middleware
2+
3+
import (
4+
"net"
5+
"net/http"
6+
"strconv"
7+
"strings"
8+
"sync"
9+
"time"
10+
)
11+
12+
// AuthThrottle limits repeated failing requests per client IP within a time
13+
// window. It is meant for authentication-style endpoints (admin password auth,
14+
// and any other endpoint where a caller repeatedly retries credentials) to slow
15+
// brute-force without ever throttling a caller that succeeds.
16+
//
17+
// Only failures are counted, and the per-IP limit is checked before the wrapped
18+
// handler runs, which gives two properties that matter for auth endpoints:
19+
//
20+
// - A caller presenting valid credentials is never throttled, even while a
21+
// different source is actively brute-forcing: success does not accrue against
22+
// the limit, and each source IP is counted independently.
23+
// - Once an IP is over the limit its requests are rejected before the handler
24+
// runs, so the response cannot reveal whether the submitted credentials were
25+
// valid.
26+
//
27+
// It is safe for concurrent use. Wrap a handler with Middleware:
28+
//
29+
// throttle := middleware.NewAuthThrottle(10, time.Minute, nil)
30+
// mux.Handle(path, throttle.Middleware(handler))
31+
type AuthThrottle struct {
32+
max int
33+
window time.Duration
34+
isFailure func(status int) bool
35+
36+
mu sync.Mutex
37+
failures map[string]int
38+
windowEnd time.Time
39+
}
40+
41+
// authThrottleMapCap bounds the number of tracked IPs so a flood of requests with
42+
// varying source addresses cannot grow the map without limit.
43+
const authThrottleMapCap = 10000
44+
45+
// NewAuthThrottle creates an AuthThrottle allowing at most max failing requests
46+
// per client IP within window, after which further requests from that IP are
47+
// rejected with 429 until the window rolls over. isFailure decides, from the
48+
// status the wrapped handler wrote, whether a request counts as a failure; if
49+
// nil, any status >= 400 counts.
50+
func NewAuthThrottle(max int, window time.Duration, isFailure func(status int) bool) *AuthThrottle {
51+
if isFailure == nil {
52+
isFailure = func(status int) bool { return status >= 400 }
53+
}
54+
return &AuthThrottle{
55+
max: max,
56+
window: window,
57+
isFailure: isFailure,
58+
failures: make(map[string]int),
59+
}
60+
}
61+
62+
// Middleware wraps h with per-IP failure throttling.
63+
func (t *AuthThrottle) Middleware(h http.Handler) http.Handler {
64+
retryAfter := strconv.Itoa(int(t.window.Seconds()))
65+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66+
ip := clientIP(r)
67+
if !t.allow(ip) {
68+
w.Header().Set("Retry-After", retryAfter)
69+
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
70+
return
71+
}
72+
sw := &statusResponseWriter{ResponseWriter: w, status: http.StatusOK}
73+
h.ServeHTTP(sw, r)
74+
if t.isFailure(sw.Status()) {
75+
t.recordFailure(ip)
76+
}
77+
})
78+
}
79+
80+
// allow reports whether another request from ip may reach the handler. It rolls
81+
// the window over, discarding accumulated counts once the window elapses.
82+
func (t *AuthThrottle) allow(ip string) bool {
83+
t.mu.Lock()
84+
defer t.mu.Unlock()
85+
if now := time.Now(); now.After(t.windowEnd) {
86+
t.failures = make(map[string]int)
87+
t.windowEnd = now.Add(t.window)
88+
}
89+
return t.failures[ip] < t.max
90+
}
91+
92+
// recordFailure counts one failed request from ip. New IPs are not tracked once
93+
// the map is at capacity, keeping memory bounded under an address-varying flood.
94+
func (t *AuthThrottle) recordFailure(ip string) {
95+
t.mu.Lock()
96+
defer t.mu.Unlock()
97+
if _, tracked := t.failures[ip]; !tracked && len(t.failures) >= authThrottleMapCap {
98+
return
99+
}
100+
t.failures[ip]++
101+
}
102+
103+
// clientIP derives the client address used as the throttle key.
104+
//
105+
// Forwarded headers are only trusted when the immediate socket peer is a private
106+
// or loopback address, i.e. a local reverse proxy or load balancer (Centrifugo is
107+
// commonly deployed behind one). For a direct public client the headers are
108+
// client-controlled and ignored, so a peer cannot spoof them to evade throttling
109+
// or lock out another IP. Header values are validated and canonicalized as IPs
110+
// before use, so junk cannot inflate map keys or fragment the keyspace.
111+
func clientIP(r *http.Request) string {
112+
host, _, err := net.SplitHostPort(r.RemoteAddr)
113+
if err != nil {
114+
host = r.RemoteAddr
115+
}
116+
peer := net.ParseIP(host)
117+
if peer == nil || !(peer.IsLoopback() || peer.IsPrivate()) {
118+
return host
119+
}
120+
// Peer is a trusted local proxy: use the forwarded client address.
121+
if ip := parseIP(r.Header.Get("X-Real-IP")); ip != "" {
122+
return ip
123+
}
124+
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
125+
if i := strings.IndexByte(fwd, ','); i >= 0 {
126+
fwd = fwd[:i]
127+
}
128+
if ip := parseIP(strings.TrimSpace(fwd)); ip != "" {
129+
return ip
130+
}
131+
}
132+
return host
133+
}
134+
135+
// parseIP returns the canonical string form of s if it is a valid IP, else "".
136+
func parseIP(s string) string {
137+
if s == "" {
138+
return ""
139+
}
140+
if ip := net.ParseIP(s); ip != nil {
141+
return ip.String()
142+
}
143+
return ""
144+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package middleware
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"strconv"
7+
"strings"
8+
"testing"
9+
"time"
10+
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// goodPassword is accepted by the test handler; anything else returns 400.
15+
const goodPassword = "correct"
16+
17+
func throttleTestHandler() http.Handler {
18+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+
if r.URL.Query().Get("pw") == goodPassword {
20+
w.WriteHeader(http.StatusOK)
21+
return
22+
}
23+
http.Error(w, "bad", http.StatusBadRequest)
24+
})
25+
}
26+
27+
func doReq(h http.Handler, ip, pw string) *httptest.ResponseRecorder {
28+
r := httptest.NewRequest(http.MethodPost, "/auth?pw="+pw, nil)
29+
r.RemoteAddr = "10.0.0.1:12345" // trusted local proxy peer, so X-Real-IP is honored.
30+
r.Header.Set("X-Real-IP", ip)
31+
w := httptest.NewRecorder()
32+
h.ServeHTTP(w, r)
33+
return w
34+
}
35+
36+
func TestAuthThrottle_LimitsFailuresPerIP(t *testing.T) {
37+
h := NewAuthThrottle(3, time.Minute, nil).Middleware(throttleTestHandler())
38+
39+
// First 3 wrong attempts from an IP reach the handler and return its status.
40+
for i := 0; i < 3; i++ {
41+
require.Equal(t, http.StatusBadRequest, doReq(h, "1.1.1.1", "wrong").Code, "attempt %d", i)
42+
}
43+
// The 4th is rejected before the handler runs, with a Retry-After hint.
44+
resp := doReq(h, "1.1.1.1", "wrong")
45+
require.Equal(t, http.StatusTooManyRequests, resp.Code)
46+
require.Equal(t, strconv.Itoa(60), resp.Header().Get("Retry-After"))
47+
48+
// Even a correct password from the throttled IP is rejected with 429, so the
49+
// response does not reveal that the credentials were valid.
50+
require.Equal(t, http.StatusTooManyRequests, doReq(h, "1.1.1.1", goodPassword).Code)
51+
}
52+
53+
func TestAuthThrottle_DoesNotBlockOtherIPsOrSuccess(t *testing.T) {
54+
h := NewAuthThrottle(3, time.Minute, nil).Middleware(throttleTestHandler())
55+
56+
// Exhaust the limit for the attacker's IP.
57+
for i := 0; i < 5; i++ {
58+
_ = doReq(h, "9.9.9.9", "wrong")
59+
}
60+
require.Equal(t, http.StatusTooManyRequests, doReq(h, "9.9.9.9", "wrong").Code)
61+
62+
// A legitimate admin on a different IP still logs in while the attack runs.
63+
require.Equal(t, http.StatusOK, doReq(h, "2.2.2.2", goodPassword).Code)
64+
65+
// Success never accrues against the limit: many valid logins stay allowed.
66+
for i := 0; i < 20; i++ {
67+
require.Equal(t, http.StatusOK, doReq(h, "3.3.3.3", goodPassword).Code, "login %d", i)
68+
}
69+
}
70+
71+
func TestAuthThrottle_PublicPeerCannotSpoofHeader(t *testing.T) {
72+
h := NewAuthThrottle(3, time.Minute, nil).Middleware(throttleTestHandler())
73+
// A direct public client rotating X-Real-IP cannot evade the limit: the header
74+
// is untrusted (peer is not a local proxy), so every attempt keys on the real
75+
// socket peer and the IP is throttled after the limit regardless of the header.
76+
req := func(spoofedIP string) int {
77+
r := httptest.NewRequest(http.MethodPost, "/auth?pw=wrong", nil)
78+
r.RemoteAddr = "203.0.113.5:9999" // public peer.
79+
r.Header.Set("X-Real-IP", spoofedIP)
80+
w := httptest.NewRecorder()
81+
h.ServeHTTP(w, r)
82+
return w.Code
83+
}
84+
require.Equal(t, http.StatusBadRequest, req("1.1.1.1"))
85+
require.Equal(t, http.StatusBadRequest, req("2.2.2.2"))
86+
require.Equal(t, http.StatusBadRequest, req("3.3.3.3"))
87+
require.Equal(t, http.StatusTooManyRequests, req("4.4.4.4"))
88+
}
89+
90+
func TestAuthThrottle_WindowReset(t *testing.T) {
91+
h := NewAuthThrottle(2, 50*time.Millisecond, nil).Middleware(throttleTestHandler())
92+
93+
require.Equal(t, http.StatusBadRequest, doReq(h, "1.2.3.4", "wrong").Code)
94+
require.Equal(t, http.StatusBadRequest, doReq(h, "1.2.3.4", "wrong").Code)
95+
require.Equal(t, http.StatusTooManyRequests, doReq(h, "1.2.3.4", "wrong").Code)
96+
97+
// After the window elapses the count resets and the IP may try again.
98+
time.Sleep(70 * time.Millisecond)
99+
require.Equal(t, http.StatusBadRequest, doReq(h, "1.2.3.4", "wrong").Code)
100+
}
101+
102+
func TestAuthThrottle_MapBounded(t *testing.T) {
103+
tr := NewAuthThrottle(1, time.Minute, nil)
104+
// Track an IP, then flood the map with unique addresses past its capacity.
105+
tr.recordFailure("10.0.0.1")
106+
for i := 0; i < authThrottleMapCap+100; i++ {
107+
tr.recordFailure("10.9." + strconv.Itoa(i/256) + "." + strconv.Itoa(i%256))
108+
}
109+
// The already-tracked IP keeps counting even though the map is now full.
110+
tr.recordFailure("10.0.0.1")
111+
112+
tr.mu.Lock()
113+
size := len(tr.failures)
114+
pretrackedCount := tr.failures["10.0.0.1"]
115+
tr.mu.Unlock()
116+
require.LessOrEqual(t, size, authThrottleMapCap, "map must stay bounded")
117+
require.Equal(t, 2, pretrackedCount, "already-tracked IP keeps counting")
118+
}
119+
120+
func TestClientIP(t *testing.T) {
121+
newReq := func(realIP, xff, remote string) *http.Request {
122+
r := httptest.NewRequest(http.MethodPost, "/", nil)
123+
r.RemoteAddr = remote
124+
if realIP != "" {
125+
r.Header.Set("X-Real-IP", realIP)
126+
}
127+
if xff != "" {
128+
r.Header.Set("X-Forwarded-For", xff)
129+
}
130+
return r
131+
}
132+
// Trusted local proxy peer (private / loopback): forwarded headers are used.
133+
require.Equal(t, "5.5.5.5", clientIP(newReq("5.5.5.5", "1.1.1.1", "10.0.0.9:1")))
134+
require.Equal(t, "1.1.1.1", clientIP(newReq("", "1.1.1.1, 2.2.2.2", "10.0.0.9:1")))
135+
require.Equal(t, "8.8.8.8", clientIP(newReq("8.8.8.8", "", "127.0.0.1:1")))
136+
137+
// Direct public peer: forwarded headers are client-controlled, so ignored -
138+
// the socket peer is used and a spoofed header cannot change the key.
139+
require.Equal(t, "9.9.9.9", clientIP(newReq("", "", "9.9.9.9:12345")))
140+
require.Equal(t, "9.9.9.9", clientIP(newReq("1.2.3.4", "1.2.3.4", "9.9.9.9:12345")))
141+
142+
// Behind a trusted proxy, a non-IP header value falls back to the peer, so an
143+
// attacker cannot inflate map keys or fragment the keyspace with junk.
144+
require.Equal(t, "10.0.0.9", clientIP(newReq(strings.Repeat("x", 5000), "", "10.0.0.9:1")))
145+
require.Equal(t, "10.0.0.9", clientIP(newReq("", "not-an-ip", "10.0.0.9:1")))
146+
147+
require.Equal(t, "raw-addr", clientIP(newReq("", "", "raw-addr")))
148+
}

0 commit comments

Comments
 (0)