snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
58
internal/middleware/auth.go
Normal file
58
internal/middleware/auth.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/tools"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// APIKeyAuth middleware authorizes request using API key authorization.
|
||||
// It first tries to use Authorization header to extract API key
|
||||
// (Authorization: apikey <KEY>), then checks for api_key URL query parameter.
|
||||
// If key not found or invalid then 401 response code is returned.
|
||||
type APIKeyAuth struct {
|
||||
key string
|
||||
}
|
||||
|
||||
func NewAPIKeyAuth(key string) *APIKeyAuth {
|
||||
return &APIKeyAuth{key: key}
|
||||
}
|
||||
|
||||
func (a *APIKeyAuth) Middleware(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if a.key == "" {
|
||||
log.Error().Msg("API key is empty")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
authValid := false
|
||||
authHeaderValue := r.Header.Get("X-API-Key")
|
||||
if authHeaderValue != "" {
|
||||
if tools.SecureCompareString(a.key, authHeaderValue) {
|
||||
authValid = true
|
||||
}
|
||||
} else {
|
||||
authHeaderAuthorization := r.Header.Get("Authorization")
|
||||
if authHeaderAuthorization != "" {
|
||||
parts := strings.Fields(authHeaderAuthorization)
|
||||
if len(parts) == 2 && strings.ToLower(parts[0]) == "apikey" && tools.SecureCompareString(a.key, parts[1]) {
|
||||
authValid = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !authValid && r.URL.RawQuery != "" {
|
||||
// Check URL param.
|
||||
if tools.SecureCompareString(a.key, r.URL.Query().Get("api_key")) {
|
||||
authValid = true
|
||||
}
|
||||
}
|
||||
if !authValid {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
105
internal/middleware/auth_test.go
Normal file
105
internal/middleware/auth_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func testHandler() http.Handler {
|
||||
fn := func(rw http.ResponseWriter, req *http.Request) {}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
|
||||
func TestAPIKeyAuthEmptyKey(t *testing.T) {
|
||||
ts := httptest.NewServer(NewAPIKeyAuth("").Middleware(testHandler()))
|
||||
defer ts.Close()
|
||||
|
||||
res, err := http.Get(ts.URL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, res.StatusCode, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func TestAPIKeyAuthMissingAuthKey(t *testing.T) {
|
||||
ts := httptest.NewServer(NewAPIKeyAuth("test").Middleware(testHandler()))
|
||||
defer ts.Close()
|
||||
|
||||
res, err := http.Get(ts.URL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, res.StatusCode, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func TestAPIKeyAuthXAPIKey(t *testing.T) {
|
||||
ts := httptest.NewServer(NewAPIKeyAuth("test").Middleware(testHandler()))
|
||||
defer ts.Close()
|
||||
|
||||
req, err := http.NewRequest("POST", ts.URL, nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("X-API-Key", "test")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, res.StatusCode, http.StatusOK)
|
||||
_ = res.Body.Close()
|
||||
|
||||
req, err = http.NewRequest("POST", ts.URL, nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("x-api-key", "test")
|
||||
res, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, res.StatusCode, http.StatusOK)
|
||||
_ = res.Body.Close()
|
||||
}
|
||||
|
||||
func TestAPIKeyAuthAuthorizationHeader(t *testing.T) {
|
||||
ts := httptest.NewServer(NewAPIKeyAuth("test").Middleware(testHandler()))
|
||||
defer ts.Close()
|
||||
|
||||
req, err := http.NewRequest("POST", ts.URL, nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "bearer test")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, res.StatusCode, http.StatusUnauthorized)
|
||||
_ = res.Body.Close()
|
||||
|
||||
req, err = http.NewRequest("POST", ts.URL, nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "test")
|
||||
res, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, res.StatusCode, http.StatusUnauthorized)
|
||||
_ = res.Body.Close()
|
||||
|
||||
req, err = http.NewRequest("POST", ts.URL, nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "apikey test test")
|
||||
res, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, res.StatusCode, http.StatusUnauthorized)
|
||||
_ = res.Body.Close()
|
||||
|
||||
req, err = http.NewRequest("POST", ts.URL, nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "apikey test")
|
||||
res, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, res.StatusCode, http.StatusOK)
|
||||
_ = res.Body.Close()
|
||||
}
|
||||
|
||||
func TestAPIKeyAuthQueryParam(t *testing.T) {
|
||||
ts := httptest.NewServer(NewAPIKeyAuth("test").Middleware(testHandler()))
|
||||
defer ts.Close()
|
||||
|
||||
res, err := http.Post(ts.URL+"?api_key=t", "application/json", nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, res.StatusCode, http.StatusUnauthorized)
|
||||
_ = res.Body.Close()
|
||||
|
||||
res, err = http.Post(ts.URL+"?api_key=test", "application/json", nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, res.StatusCode, http.StatusOK)
|
||||
_ = res.Body.Close()
|
||||
}
|
||||
144
internal/middleware/auththrottle.go
Normal file
144
internal/middleware/auththrottle.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuthThrottle limits repeated failing requests per client IP within a time
|
||||
// window. It is meant for authentication-style endpoints (admin password auth,
|
||||
// and any other endpoint where a caller repeatedly retries credentials) to slow
|
||||
// brute-force without ever throttling a caller that succeeds.
|
||||
//
|
||||
// Only failures are counted, and the per-IP limit is checked before the wrapped
|
||||
// handler runs, which gives two properties that matter for auth endpoints:
|
||||
//
|
||||
// - A caller presenting valid credentials is never throttled, even while a
|
||||
// different source is actively brute-forcing: success does not accrue against
|
||||
// the limit, and each source IP is counted independently.
|
||||
// - Once an IP is over the limit its requests are rejected before the handler
|
||||
// runs, so the response cannot reveal whether the submitted credentials were
|
||||
// valid.
|
||||
//
|
||||
// It is safe for concurrent use. Wrap a handler with Middleware:
|
||||
//
|
||||
// throttle := middleware.NewAuthThrottle(10, time.Minute, nil)
|
||||
// mux.Handle(path, throttle.Middleware(handler))
|
||||
type AuthThrottle struct {
|
||||
max int
|
||||
window time.Duration
|
||||
isFailure func(status int) bool
|
||||
|
||||
mu sync.Mutex
|
||||
failures map[string]int
|
||||
windowEnd time.Time
|
||||
}
|
||||
|
||||
// authThrottleMapCap bounds the number of tracked IPs so a flood of requests with
|
||||
// varying source addresses cannot grow the map without limit.
|
||||
const authThrottleMapCap = 10000
|
||||
|
||||
// NewAuthThrottle creates an AuthThrottle allowing at most max failing requests
|
||||
// per client IP within window, after which further requests from that IP are
|
||||
// rejected with 429 until the window rolls over. isFailure decides, from the
|
||||
// status the wrapped handler wrote, whether a request counts as a failure; if
|
||||
// nil, any status >= 400 counts.
|
||||
func NewAuthThrottle(max int, window time.Duration, isFailure func(status int) bool) *AuthThrottle {
|
||||
if isFailure == nil {
|
||||
isFailure = func(status int) bool { return status >= 400 }
|
||||
}
|
||||
return &AuthThrottle{
|
||||
max: max,
|
||||
window: window,
|
||||
isFailure: isFailure,
|
||||
failures: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware wraps h with per-IP failure throttling.
|
||||
func (t *AuthThrottle) Middleware(h http.Handler) http.Handler {
|
||||
retryAfter := strconv.Itoa(int(t.window.Seconds()))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r)
|
||||
if !t.allow(ip) {
|
||||
w.Header().Set("Retry-After", retryAfter)
|
||||
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
sw := &statusResponseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
h.ServeHTTP(sw, r)
|
||||
if t.isFailure(sw.Status()) {
|
||||
t.recordFailure(ip)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// allow reports whether another request from ip may reach the handler. It rolls
|
||||
// the window over, discarding accumulated counts once the window elapses.
|
||||
func (t *AuthThrottle) allow(ip string) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if now := time.Now(); now.After(t.windowEnd) {
|
||||
t.failures = make(map[string]int)
|
||||
t.windowEnd = now.Add(t.window)
|
||||
}
|
||||
return t.failures[ip] < t.max
|
||||
}
|
||||
|
||||
// recordFailure counts one failed request from ip. New IPs are not tracked once
|
||||
// the map is at capacity, keeping memory bounded under an address-varying flood.
|
||||
func (t *AuthThrottle) recordFailure(ip string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if _, tracked := t.failures[ip]; !tracked && len(t.failures) >= authThrottleMapCap {
|
||||
return
|
||||
}
|
||||
t.failures[ip]++
|
||||
}
|
||||
|
||||
// clientIP derives the client address used as the throttle key.
|
||||
//
|
||||
// Forwarded headers are only trusted when the immediate socket peer is a private
|
||||
// or loopback address, i.e. a local reverse proxy or load balancer (Centrifugo is
|
||||
// commonly deployed behind one). For a direct public client the headers are
|
||||
// client-controlled and ignored, so a peer cannot spoof them to evade throttling
|
||||
// or lock out another IP. Header values are validated and canonicalized as IPs
|
||||
// before use, so junk cannot inflate map keys or fragment the keyspace.
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
peer := net.ParseIP(host)
|
||||
if peer == nil || (!peer.IsLoopback() && !peer.IsPrivate()) {
|
||||
return host
|
||||
}
|
||||
// Peer is a trusted local proxy: use the forwarded client address.
|
||||
if ip := parseIP(r.Header.Get("X-Real-IP")); ip != "" {
|
||||
return ip
|
||||
}
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
if i := strings.IndexByte(fwd, ','); i >= 0 {
|
||||
fwd = fwd[:i]
|
||||
}
|
||||
if ip := parseIP(strings.TrimSpace(fwd)); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// parseIP returns the canonical string form of s if it is a valid IP, else "".
|
||||
func parseIP(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if ip := net.ParseIP(s); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
148
internal/middleware/auththrottle_test.go
Normal file
148
internal/middleware/auththrottle_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
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")))
|
||||
}
|
||||
53
internal/middleware/connlimit.go
Normal file
53
internal/middleware/connlimit.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/config"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/metrics"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/tools"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
var connLimitReachedLogLimiter = tools.NewIntervalLimiter(3 * time.Second)
|
||||
|
||||
type ConnLimit struct {
|
||||
node *centrifuge.Node
|
||||
cfgContainer *config.Container
|
||||
rl *rate.Limiter
|
||||
}
|
||||
|
||||
func NewConnLimit(node *centrifuge.Node, cfgContainer *config.Container) *ConnLimit {
|
||||
rl := connectionRateLimiter(cfgContainer.Config().Client.ConnectionRateLimit)
|
||||
return &ConnLimit{node: node, cfgContainer: cfgContainer, rl: rl}
|
||||
}
|
||||
|
||||
func (l *ConnLimit) Middleware(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if l.rl != nil && !l.rl.Allow() {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
connLimit := l.cfgContainer.Config().Client.ConnectionLimit
|
||||
if connLimit > 0 && l.node.Hub().NumClients() >= connLimit {
|
||||
metrics.ConnLimitReached.Inc()
|
||||
if connLimitReachedLogLimiter.Allow() {
|
||||
log.Warn().Int("limit", connLimit).Msg("node connection limit reached")
|
||||
}
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func connectionRateLimiter(connRateLimit int) *rate.Limiter {
|
||||
if connRateLimit > 0 {
|
||||
return rate.NewLimiter(rate.Limit(connRateLimit), connRateLimit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
41
internal/middleware/connlimit_test.go
Normal file
41
internal/middleware/connlimit_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/config"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/tools"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
func TestConnLimit_ConnectionRate(t *testing.T) {
|
||||
node := tools.NodeWithMemoryEngineNoHandlers()
|
||||
defer func() { _ = node.Shutdown(context.Background()) }()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Client.ConnectionRateLimit = 10
|
||||
cfgContainer, err := config.NewContainer(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
connLimit := NewConnLimit(node, cfgContainer)
|
||||
require.Equal(t, rate.Limit(10), connLimit.rl.Limit())
|
||||
require.Equal(t, 10, connLimit.rl.Burst())
|
||||
|
||||
ts := httptest.NewServer(connLimit.Middleware(testHandler()))
|
||||
defer ts.Close()
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
res, err := http.Post(ts.URL, "application/json", nil)
|
||||
require.NoError(t, err)
|
||||
if res.StatusCode == http.StatusServiceUnavailable {
|
||||
require.True(t, i >= 10)
|
||||
return
|
||||
}
|
||||
}
|
||||
require.Fail(t, "no rate limit hit upon sending 10 requests to a server")
|
||||
}
|
||||
28
internal/middleware/cors.go
Normal file
28
internal/middleware/cors.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
type OriginCheck func(r *http.Request) bool
|
||||
|
||||
// CORS middleware.
|
||||
type CORS struct {
|
||||
originCheck OriginCheck
|
||||
}
|
||||
|
||||
func NewCORS(originCheck OriginCheck) *CORS {
|
||||
return &CORS{originCheck: originCheck}
|
||||
}
|
||||
|
||||
func (c *CORS) Middleware(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
header := w.Header()
|
||||
if c.originCheck(r) {
|
||||
header.Set("Access-Control-Allow-Origin", r.Header.Get("origin"))
|
||||
if allowHeaders := r.Header.Get("Access-Control-Request-Headers"); allowHeaders != "" && allowHeaders != "null" {
|
||||
header.Add("Access-Control-Allow-Headers", allowHeaders)
|
||||
}
|
||||
header.Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
29
internal/middleware/headers.go
Normal file
29
internal/middleware/headers.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type contextHeadersKey struct{}
|
||||
|
||||
// GetHeadersFromContext returns http.Header from context.
|
||||
func GetHeadersFromContext(ctx context.Context) (http.Header, bool) {
|
||||
if val := ctx.Value(contextHeadersKey{}); val != nil {
|
||||
values, ok := val.(http.Header)
|
||||
return values, ok
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func SetHeadersToContext(ctx context.Context, h http.Header) context.Context {
|
||||
return context.WithValue(ctx, contextHeadersKey{}, h)
|
||||
}
|
||||
|
||||
// HeadersToContext puts HTTP headers to request context.
|
||||
func HeadersToContext(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r = r.WithContext(SetHeadersToContext(r.Context(), r.Header))
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
30
internal/middleware/http_instrumentation.go
Normal file
30
internal/middleware/http_instrumentation.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/metrics"
|
||||
)
|
||||
|
||||
// HTTPServerInstrumentation is a middleware to instrument HTTP handlers.
|
||||
// Since it adds and extra layer of response wrapping Centrifugo doesn't use it by default.
|
||||
// Note, we can not simply collect durations here because we have handlers with long-lived
|
||||
// connections which require special care. So for now we just count requests.
|
||||
func HTTPServerInstrumentation(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rw := &statusResponseWriter{w, http.StatusOK}
|
||||
next.ServeHTTP(rw, r)
|
||||
status := strconv.Itoa(rw.status)
|
||||
// Use the matched route pattern, not the raw request path. Subtree
|
||||
// handlers (admin, swagger, dev, debug) accept arbitrary client-controlled
|
||||
// sub-paths, so labeling by r.URL.Path would grow the Prometheus series map
|
||||
// without bound (memory DoS). r.Pattern is the registered pattern that
|
||||
// matched (e.g. "/admin/"), so cardinality is bounded by the route count.
|
||||
path := r.Pattern
|
||||
if path == "" {
|
||||
path = "other"
|
||||
}
|
||||
metrics.HTTPRequestsTotal.WithLabelValues(path, r.Method, status).Inc()
|
||||
})
|
||||
}
|
||||
81
internal/middleware/log.go
Normal file
81
internal/middleware/log.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/quic-go/quic-go/http3"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// LogRequest middleware logs details of request.
|
||||
func LogRequest(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
lrw := &statusResponseWriter{w, http.StatusOK}
|
||||
h.ServeHTTP(lrw, r)
|
||||
addr := r.Header.Get("X-Real-IP")
|
||||
if addr == "" {
|
||||
addr = r.Header.Get("X-Forwarded-For")
|
||||
if addr == "" {
|
||||
addr = r.RemoteAddr
|
||||
}
|
||||
}
|
||||
log.Debug().Str("method", r.Method).Int("status", lrw.Status()).Str("path", r.URL.Path).Str("addr", addr).Str("duration", time.Since(start).String()).Msg("http request")
|
||||
})
|
||||
}
|
||||
|
||||
type statusResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
// WriteHeader allows us to save status code.
|
||||
func (lrw *statusResponseWriter) WriteHeader(status int) {
|
||||
lrw.status = status
|
||||
lrw.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
// Status code allows to get saved status code after handler finished its work.
|
||||
func (lrw *statusResponseWriter) Status() int {
|
||||
if lrw.status == 0 {
|
||||
return http.StatusOK
|
||||
}
|
||||
return lrw.status
|
||||
}
|
||||
|
||||
// Hijack as we need it for Websocket.
|
||||
func (lrw *statusResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
lrw.status = http.StatusSwitchingProtocols
|
||||
hijacker, ok := lrw.ResponseWriter.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("ResponseWriter doesn't support Hijacker interface")
|
||||
}
|
||||
return hijacker.Hijack()
|
||||
}
|
||||
|
||||
func (lrw *statusResponseWriter) HTTPStream() *http3.Stream {
|
||||
return lrw.ResponseWriter.(http3.HTTPStreamer).HTTPStream()
|
||||
}
|
||||
|
||||
// Flush implements http.Flusher.
|
||||
func (lrw *statusResponseWriter) Flush() {
|
||||
lrw.ResponseWriter.(http.Flusher).Flush()
|
||||
}
|
||||
|
||||
// Unwrap is required for http.ResponseController.
|
||||
func (lrw *statusResponseWriter) Unwrap() http.ResponseWriter {
|
||||
return lrw.ResponseWriter
|
||||
}
|
||||
|
||||
func (lrw *statusResponseWriter) ReceivedSettings() <-chan struct{} {
|
||||
return lrw.ResponseWriter.(http3.Settingser).ReceivedSettings()
|
||||
}
|
||||
|
||||
func (lrw *statusResponseWriter) Settings() *http3.Settings {
|
||||
return lrw.ResponseWriter.(http3.Settingser).Settings()
|
||||
}
|
||||
20
internal/middleware/method.go
Normal file
20
internal/middleware/method.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Post checks that handler called via POST HTTP method.
|
||||
func Post(h http.Handler) http.Handler {
|
||||
return Method(http.MethodPost, h)
|
||||
}
|
||||
|
||||
func Method(method string, h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != method {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
20
internal/middleware/otel.go
Normal file
20
internal/middleware/otel.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
type OpenTelemetryHandler struct {
|
||||
operation string
|
||||
opts []otelhttp.Option
|
||||
}
|
||||
|
||||
func NewOpenTelemetryHandler(operation string, opts []otelhttp.Option) *OpenTelemetryHandler {
|
||||
return &OpenTelemetryHandler{operation: operation, opts: opts}
|
||||
}
|
||||
|
||||
func (t *OpenTelemetryHandler) Middleware(h http.Handler) http.Handler {
|
||||
return otelhttp.NewHandler(h, t.operation, t.opts...)
|
||||
}
|
||||
25
internal/middleware/user_header_auth.go
Normal file
25
internal/middleware/user_header_auth.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
)
|
||||
|
||||
// UserHeaderAuth is a middleware that extracts the value of user ID from the specific header
|
||||
// and sets connection credentials.
|
||||
func UserHeaderAuth(userHeaderName string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Header.Get(userHeaderName)
|
||||
if userID != "" {
|
||||
ctx := centrifuge.SetCredentials(r.Context(), ¢rifuge.Credentials{
|
||||
UserID: userID,
|
||||
})
|
||||
r = r.WithContext(ctx)
|
||||
}
|
||||
// Call the next handler
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
46
internal/middleware/user_header_auth_test.go
Normal file
46
internal/middleware/user_header_auth_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func userHeaderAuthTestHandler(t *testing.T, userMustBeSet bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cred, ok := centrifuge.GetCredentials(r.Context())
|
||||
if userMustBeSet {
|
||||
require.True(t, ok, "credentials should be set")
|
||||
require.Equal(t, "123", cred.UserID, "user ID should be set correctly")
|
||||
} else {
|
||||
require.False(t, ok)
|
||||
}
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserHeaderAuthWithUserID(t *testing.T) {
|
||||
middleware := UserHeaderAuth("X-User-ID")
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-User-ID", "123")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler := middleware(userHeaderAuthTestHandler(t, true))
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rr.Code, "status code should be 200 OK")
|
||||
}
|
||||
|
||||
func TestUserHeaderAuthWithoutUserID(t *testing.T) {
|
||||
middleware := UserHeaderAuth("X-User-ID")
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler := middleware(userHeaderAuthTestHandler(t, false))
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rr.Code, "status code should be 200 OK")
|
||||
}
|
||||
Reference in New Issue
Block a user