mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 13:18:56 +02:00
Adds a Redis-backed fixed-window rate limiter middleware on /auth/send-code, /auth/verify-code, and /auth/google. Prevents brute-force enumeration, verification_code table flooding, and connection pool exhaustion from rapid-fire unauthenticated requests. Key design decisions per reviewer feedback: - X-Forwarded-For trust model: XFF is NEVER trusted by default. Only honored when RemoteAddr is from a CIDR in RATE_LIMIT_TRUSTED_PROXIES. Uses rightmost-untrusted algorithm (walks XFF right-to-left, returns first non-trusted IP). Matches the project's conservative model in health_realtime.go. - Atomic INCR+EXPIRE via Lua script: prevents a stuck key (permanent ban) if EXPIRE fails independently. Follows existing Lua script pattern in runtime_local_skills_redis_store.go. - Fixed-window counter (not sliding-window): simple, adequate for auth rate limiting where precision at window boundaries is acceptable. - Fail-open with startup warning: nil Redis disables rate limiting (same as PATCache), but logs a warning at startup so ops can see. - IPv6 normalization: net.ParseIP().String() produces canonical form. - Configurable via env vars: RATE_LIMIT_AUTH (default 5/min), RATE_LIMIT_AUTH_VERIFY (default 20/min), RATE_LIMIT_TRUSTED_PROXIES. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
246 lines
7.0 KiB
Go
246 lines
7.0 KiB
Go
package middleware
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var okHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
func TestRateLimit_NilRedis(t *testing.T) {
|
|
mw := RateLimit(nil, 5, time.Minute, nil)
|
|
handler := mw(okHandler)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/send-code", nil)
|
|
req.RemoteAddr = "1.2.3.4:12345"
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("nil redis should pass through; got status %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRateLimit_AllowsUnderLimit(t *testing.T) {
|
|
rdb := newRedisTestClient(t)
|
|
mw := RateLimit(rdb, 3, time.Minute, nil)
|
|
handler := mw(okHandler)
|
|
|
|
for i := 0; i < 3; i++ {
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/send-code", nil)
|
|
req.RemoteAddr = "10.0.0.1:9000"
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("request %d: expected 200, got %d", i+1, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRateLimit_BlocksOverLimit(t *testing.T) {
|
|
rdb := newRedisTestClient(t)
|
|
mw := RateLimit(rdb, 2, time.Minute, nil)
|
|
handler := mw(okHandler)
|
|
|
|
for i := 0; i < 2; i++ {
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/send-code", nil)
|
|
req.RemoteAddr = "10.0.0.2:9000"
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("request %d: expected 200, got %d", i+1, rec.Code)
|
|
}
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/send-code", nil)
|
|
req.RemoteAddr = "10.0.0.2:9000"
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("expected 429, got %d", rec.Code)
|
|
}
|
|
|
|
var body map[string]string
|
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if body["error"] != "too many requests" {
|
|
t.Fatalf("unexpected error message: %q", body["error"])
|
|
}
|
|
}
|
|
|
|
func TestRateLimit_RetryAfterHeader(t *testing.T) {
|
|
rdb := newRedisTestClient(t)
|
|
mw := RateLimit(rdb, 1, 2*time.Minute, nil)
|
|
handler := mw(okHandler)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/send-code", nil)
|
|
req.RemoteAddr = "10.0.0.3:9000"
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
req = httptest.NewRequest(http.MethodPost, "/auth/send-code", nil)
|
|
req.RemoteAddr = "10.0.0.3:9000"
|
|
rec = httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("expected 429, got %d", rec.Code)
|
|
}
|
|
retryAfter := rec.Header().Get("Retry-After")
|
|
if retryAfter != "120" {
|
|
t.Fatalf("expected Retry-After=120, got %q", retryAfter)
|
|
}
|
|
}
|
|
|
|
func TestRateLimit_DifferentIPs(t *testing.T) {
|
|
rdb := newRedisTestClient(t)
|
|
mw := RateLimit(rdb, 1, time.Minute, nil)
|
|
handler := mw(okHandler)
|
|
|
|
ips := []string{"10.0.1.1:9000", "10.0.1.2:9000", "10.0.1.3:9000"}
|
|
for _, addr := range ips {
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/send-code", nil)
|
|
req.RemoteAddr = addr
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("ip %s: expected 200, got %d", addr, rec.Code)
|
|
}
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/send-code", nil)
|
|
req.RemoteAddr = "10.0.1.1:9000"
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("expected 429 for repeated IP, got %d", rec.Code)
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodPost, "/auth/send-code", nil)
|
|
req.RemoteAddr = "10.0.1.3:9000"
|
|
rec = httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("expected 429 for repeated IP 10.0.1.3, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// X-Forwarded-For trust model tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func mustParseCIDR(cidr string) *net.IPNet {
|
|
_, n, err := net.ParseCIDR(cidr)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func TestExtractIP_NoTrustedProxies_IgnoresXFF(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/send-code", nil)
|
|
req.RemoteAddr = "203.0.113.1:9000"
|
|
req.Header.Set("X-Forwarded-For", "198.51.100.1, 203.0.113.1")
|
|
|
|
ip := extractIP(req, nil)
|
|
if ip != "203.0.113.1" {
|
|
t.Fatalf("with no trusted proxies, expected RemoteAddr; got %q", ip)
|
|
}
|
|
}
|
|
|
|
func TestExtractIP_TrustedProxy_HonorsXFF(t *testing.T) {
|
|
trusted := []*net.IPNet{mustParseCIDR("10.0.0.0/8")}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/send-code", nil)
|
|
req.RemoteAddr = "10.0.0.1:9000"
|
|
req.Header.Set("X-Forwarded-For", "198.51.100.42, 10.0.0.5")
|
|
|
|
ip := extractIP(req, trusted)
|
|
// Rightmost non-trusted: 198.51.100.42 (10.0.0.5 is trusted)
|
|
if ip != "198.51.100.42" {
|
|
t.Fatalf("expected rightmost non-trusted IP; got %q", ip)
|
|
}
|
|
}
|
|
|
|
func TestExtractIP_UntrustedSource_IgnoresXFF(t *testing.T) {
|
|
trusted := []*net.IPNet{mustParseCIDR("10.0.0.0/8")}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/send-code", nil)
|
|
req.RemoteAddr = "203.0.113.99:9000" // Not in trusted CIDR
|
|
req.Header.Set("X-Forwarded-For", "198.51.100.1")
|
|
|
|
ip := extractIP(req, trusted)
|
|
if ip != "203.0.113.99" {
|
|
t.Fatalf("untrusted source should use RemoteAddr; got %q", ip)
|
|
}
|
|
}
|
|
|
|
func TestExtractIP_IPv6Normalization(t *testing.T) {
|
|
req1 := httptest.NewRequest(http.MethodPost, "/auth/send-code", nil)
|
|
req1.RemoteAddr = "[::1]:9000"
|
|
|
|
req2 := httptest.NewRequest(http.MethodPost, "/auth/send-code", nil)
|
|
req2.RemoteAddr = "[0:0:0:0:0:0:0:1]:9000"
|
|
|
|
ip1 := extractIP(req1, nil)
|
|
ip2 := extractIP(req2, nil)
|
|
if ip1 != ip2 {
|
|
t.Fatalf("IPv6 representations should normalize to same key: %q vs %q", ip1, ip2)
|
|
}
|
|
}
|
|
|
|
func TestRateLimit_LuaScript_SetsTTL(t *testing.T) {
|
|
rdb := newRedisTestClient(t)
|
|
mw := RateLimit(rdb, 10, 30*time.Second, nil)
|
|
handler := mw(okHandler)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/send-code", nil)
|
|
req.RemoteAddr = "10.0.99.1:9000"
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
|
|
key := rateLimitKey("/auth/send-code", "10.0.99.1")
|
|
ttl, err := rdb.TTL(req.Context(), key).Result()
|
|
if err != nil {
|
|
t.Fatalf("TTL: %v", err)
|
|
}
|
|
if ttl <= 0 || ttl > 31*time.Second {
|
|
t.Fatalf("expected TTL ~30s, got %v", ttl)
|
|
}
|
|
}
|
|
|
|
func TestParseTrustedProxies_Empty(t *testing.T) {
|
|
if nets := ParseTrustedProxies(""); nets != nil {
|
|
t.Fatalf("empty string should return nil, got %v", nets)
|
|
}
|
|
if nets := ParseTrustedProxies(" "); nets != nil {
|
|
t.Fatalf("whitespace should return nil, got %v", nets)
|
|
}
|
|
}
|
|
|
|
func TestParseTrustedProxies_Valid(t *testing.T) {
|
|
nets := ParseTrustedProxies("10.0.0.0/8, 172.16.0.0/12")
|
|
if len(nets) != 2 {
|
|
t.Fatalf("expected 2 CIDRs, got %d", len(nets))
|
|
}
|
|
}
|
|
|
|
func TestParseTrustedProxies_InvalidSkipped(t *testing.T) {
|
|
nets := ParseTrustedProxies("10.0.0.0/8, not-a-cidr, 172.16.0.0/12")
|
|
if len(nets) != 2 {
|
|
t.Fatalf("expected 2 valid CIDRs (invalid skipped), got %d", len(nets))
|
|
}
|
|
}
|