From 958d873e0caf8997fe444b2d6b37a2bc77985660 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Tue, 11 Aug 2026 18:26:54 +0200 Subject: [PATCH 01/21] Work in progress: initial implementation --- sei-tendermint/config/config.go | 42 +++ sei-tendermint/config/toml.go | 22 ++ sei-tendermint/internal/inspect/rpc/rpc.go | 14 + sei-tendermint/internal/rpc/core/env.go | 13 + .../rpc/jsonrpc/server/rate_limit.go | 92 +++++++ .../jsonrpc/server/rate_limit_middleware.go | 127 +++++++++ .../server/rate_limit_middleware_test.go | 255 ++++++++++++++++++ 7 files changed, 565 insertions(+) create mode 100644 sei-tendermint/rpc/jsonrpc/server/rate_limit.go create mode 100644 sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go create mode 100644 sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go diff --git a/sei-tendermint/config/config.go b/sei-tendermint/config/config.go index 0802169e83..134ec155ee 100644 --- a/sei-tendermint/config/config.go +++ b/sei-tendermint/config/config.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/sei-protocol/sei-chain/ratelimiter" mempoolcfg "github.com/sei-protocol/sei-chain/sei-tendermint/internal/mempool" tmos "github.com/sei-protocol/sei-chain/sei-tendermint/libs/os" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" @@ -535,6 +536,29 @@ type RPCConfig struct { // concurrent search load: it is shared across requests, not applied per-query. // 0 disables the cap (not recommended on public nodes). MaxSearchScanBudget int `mapstructure:"max-search-scan-budget"` + + // IPRateLimitRPS is the per-IP sustained request rate in requests/second for + // CometBFT RPC HTTP (:26657). Zero disables the token bucket (no HTTP 429 + // rejections). When rate_limiting_enabled is true, the admission middleware + // still runs: bodies are parsed and oversize/malformed requests are rejected + // before dispatch. + IPRateLimitRPS float64 `mapstructure:"ip_rate_limit_rps"` + + // IPRateLimitBurst is the maximum per-IP burst size. Zero disables the token + // bucket (same effect as ip_rate_limit_rps = 0) and does not bypass the + // admission middleware when rate_limiting_enabled is true. Should be at least + // the JSON-RPC batch size limit (10) because the rate limiter charges one + // token per batch element. + IPRateLimitBurst int `mapstructure:"ip_rate_limit_burst"` + + // RateLimitingEnabled is the master switch for the rate-limit admission + // middleware on the CometBFT RPC HTTP plane. When false, requests bypass + // method extraction and all rejections from that layer (HTTP 400/413/429). + RateLimitingEnabled bool `mapstructure:"rate_limiting_enabled"` + + // TrustedProxyCIDRs lists CIDRs whose X-Forwarded-For headers are trusted when + // resolving the client IP for rate limiting. Empty means trust no proxy. + TrustedProxyCIDRs []string `mapstructure:"trusted_proxy_cidrs"` } // DefaultRPCConfig returns a default configuration for the RPC server @@ -570,6 +594,11 @@ func DefaultRPCConfig() *RPCConfig { MaxTxSearchResults: 10_000, MaxSearchScanBudget: 100_000, + + IPRateLimitRPS: 200, + IPRateLimitBurst: 400, + RateLimitingEnabled: true, + TrustedProxyCIDRs: nil, } } @@ -628,9 +657,22 @@ func (cfg *RPCConfig) ValidateBasic() error { if cfg.MaxSearchScanBudget < 0 { return errors.New("max-search-scan-budget can't be negative") } + if cfg.RateLimitingEnabled && cfg.IPRateLimitBurst > 0 && cfg.IPRateLimitBurst < 10 { + return fmt.Errorf("ip_rate_limit_burst (%d) must be >= 10: the rate limiter charges one token per batch element", + cfg.IPRateLimitBurst) + } return nil } +// RateLimiterConfig builds the ratelimiter.Config used by CometBFT RPC HTTP admission. +func (cfg *RPCConfig) RateLimiterConfig() ratelimiter.Config { + return ratelimiter.Config{ + RPS: cfg.IPRateLimitRPS, + Burst: cfg.IPRateLimitBurst, + TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, + } +} + // IsCorsEnabled returns true if cross-origin resource sharing is enabled. func (cfg *RPCConfig) IsCorsEnabled() bool { return len(cfg.CORSAllowedOrigins) != 0 diff --git a/sei-tendermint/config/toml.go b/sei-tendermint/config/toml.go index 620d4608c8..d40e1f2b1e 100644 --- a/sei-tendermint/config/toml.go +++ b/sei-tendermint/config/toml.go @@ -294,6 +294,28 @@ max-tx-search-results = {{ .RPC.MaxTxSearchResults }} # accumulate. Set to 0 to disable the cap (not recommended on public nodes). max-search-scan-budget = {{ .RPC.MaxSearchScanBudget }} +# ip_rate_limit_rps is the per-IP sustained request rate in requests/second for +# CometBFT RPC HTTP (:26657). Zero disables the token bucket (no HTTP 429 +# rejections). When rate_limiting_enabled is true, the admission middleware still +# runs: bodies are parsed and oversize/malformed requests are rejected before dispatch. +ip_rate_limit_rps = {{ .RPC.IPRateLimitRPS }} + +# ip_rate_limit_burst is the maximum per-IP burst above the sustained rate. +# Zero disables the token bucket (same effect as ip_rate_limit_rps = 0) and does +# not bypass the admission middleware when rate_limiting_enabled is true. Must be +# at least 10 when both are positive and rate_limiting_enabled is true because the +# rate limiter charges one token per JSON-RPC batch element. +ip_rate_limit_burst = {{ .RPC.IPRateLimitBurst }} + +# rate_limiting_enabled is the master switch for the rate-limit admission +# middleware on the CometBFT RPC HTTP plane. When false, requests bypass method +# extraction and all rejections from that layer (HTTP 400/413/429). +rate_limiting_enabled = {{ .RPC.RateLimitingEnabled }} + +# trusted_proxy_cidrs lists CIDRs whose X-Forwarded-For headers are trusted when +# resolving the client IP for rate limiting. Empty means trust no proxy. +trusted_proxy_cidrs = [{{ range .RPC.TrustedProxyCIDRs }}{{ printf "%q, " . }}{{end}}] + ####################################################################### ### P2P Configuration Options ### ####################################################################### diff --git a/sei-tendermint/internal/inspect/rpc/rpc.go b/sei-tendermint/internal/inspect/rpc/rpc.go index e59fb21792..03fc8389ce 100644 --- a/sei-tendermint/internal/inspect/rpc/rpc.go +++ b/sei-tendermint/internal/inspect/rpc/rpc.go @@ -2,11 +2,13 @@ package rpc import ( "context" + "fmt" "net/http" "github.com/rs/cors" "github.com/sei-protocol/seilog" + "github.com/sei-protocol/sei-chain/ratelimiter" "github.com/sei-protocol/sei-chain/sei-tendermint/config" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/pubsub" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/rpc/core" @@ -74,6 +76,18 @@ func Handler(rpcConfig *config.RPCConfig, routes core.RoutesMap) http.Handler { if rpcConfig.IsCorsEnabled() { rootHandler = addCORSHandler(rpcConfig, mux) } + rateLimitRegistry, err := ratelimiter.New(rpcConfig.RateLimiterConfig()) + if err != nil { + panic(fmt.Errorf("inspect rpc rate limiter: %w", err)) + } + rootHandler = server.NewRateLimitMiddleware( + rootHandler, + server.NewRateLimitGate( + rateLimitRegistry, + rpcConfig.MaxBodyBytes, + rpcConfig.RateLimitingEnabled, + ), + ) return rootHandler } diff --git a/sei-tendermint/internal/rpc/core/env.go b/sei-tendermint/internal/rpc/core/env.go index 5fada14787..ae28f48205 100644 --- a/sei-tendermint/internal/rpc/core/env.go +++ b/sei-tendermint/internal/rpc/core/env.go @@ -11,6 +11,7 @@ import ( "github.com/rs/cors" "github.com/sei-protocol/seilog" + "github.com/sei-protocol/sei-chain/ratelimiter" "github.com/sei-protocol/sei-chain/sei-tendermint/config" "github.com/sei-protocol/sei-chain/sei-tendermint/crypto" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/blocksync" @@ -362,6 +363,18 @@ func (env *Environment) StartService(ctx context.Context, conf *config.Config) ( }) rootHandler = corsMiddleware.Handler(mux) } + rateLimitRegistry, err := ratelimiter.New(conf.RPC.RateLimiterConfig()) + if err != nil { + return nil, fmt.Errorf("rpc rate limiter: %w", err) + } + rootHandler = rpcserver.NewRateLimitMiddleware( + rootHandler, + rpcserver.NewRateLimitGate( + rateLimitRegistry, + conf.RPC.MaxBodyBytes, + conf.RPC.RateLimitingEnabled, + ), + ) if conf.RPC.IsTLSEnabled() { go func() { if err := rpcserver.ServeTLS( diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit.go new file mode 100644 index 0000000000..588c625994 --- /dev/null +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit.go @@ -0,0 +1,92 @@ +package server + +import ( + "context" + "io" + "math" + "strings" + + "github.com/sei-protocol/sei-chain/ratelimiter" +) + +const cometbftRateLimitPlane = "cometbft" + +// RateLimitGate applies per-IP token-bucket rate limiting for CometBFT RPC HTTP +// requests. POST JSON-RPC bodies are parsed with MethodParser before full decode; +// GET URI routes are accounted by path-derived method names. +type RateLimitGate struct { + registry *ratelimiter.Registry + parser *ratelimiter.MethodParser + maxBodyBytes int64 + enabled bool + plane string +} + +// NewRateLimitGate returns a gate for CometBFT RPC HTTP (plane "cometbft"). +// registry must be non-nil. maxBodyBytes should match max-body-bytes; non-positive +// values use DefaultConfig().MaxBodyBytes. +func NewRateLimitGate(registry *ratelimiter.Registry, maxBodyBytes int64, enabled bool) *RateLimitGate { + if maxBodyBytes <= 0 { + maxBodyBytes = DefaultConfig().MaxBodyBytes + } + if maxBodyBytes == math.MaxInt64 { + maxBodyBytes = math.MaxInt64 - 1 + } + return &RateLimitGate{ + registry: registry, + parser: ratelimiter.NewMethodParser(maxBodyBytes), + maxBodyBytes: maxBodyBytes, + enabled: enabled, + plane: cometbftRateLimitPlane, + } +} + +// chargeAdmissionRejection consumes one token for a fail-closed rejection that +// never reaches method parsing (oversize body, read error). Returns true when +// the bucket is exhausted and the caller should respond with HTTP 429. +func (g *RateLimitGate) chargeAdmissionRejection(ctx context.Context, ip string) bool { + if !g.enabled { + return false + } + return !g.registry.Allow(ctx, ip, g.plane, ratelimiter.MethodInvalid) +} + +// CheckPOST parses body for JSON-RPC method names and applies per-IP rate limits. +// Parse errors still charge the bucket under ratelimiter.MethodInvalid so +// malformed bodies can't bypass rate limiting. +func (g *RateLimitGate) CheckPOST(ctx context.Context, ip string, body io.Reader) (allowed bool, rejectMethod string, err error) { + if !g.enabled { + return true, "", nil + } + + methods, _, parseErr := g.parser.Parse(body) + if parseErr != nil { + if !g.registry.Allow(ctx, ip, g.plane, ratelimiter.MethodInvalid) { + return false, ratelimiter.MethodInvalid, nil + } + return false, "", parseErr + } + + if n := len(methods); n > 0 && !g.registry.AllowN(ctx, ip, g.plane, methods[0], n) { + return false, methods[0], nil + } + return true, "", nil +} + +// CheckURI applies per-IP rate limits for REST-style GET/HEAD RPC routes. +func (g *RateLimitGate) CheckURI(ctx context.Context, ip, method string) (allowed bool, rejectMethod string) { + if !g.enabled { + return true, "" + } + method = strings.TrimPrefix(method, "/") + if method == "" { + if !g.registry.Allow(ctx, ip, g.plane, ratelimiter.MethodInvalid) { + return false, ratelimiter.MethodInvalid + } + return false, "" + } + if !g.registry.Allow(ctx, ip, g.plane, method) { + return false, method + } + return true, "" +} diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go new file mode 100644 index 0000000000..9dd5d7ca77 --- /dev/null +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go @@ -0,0 +1,127 @@ +package server + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + + "github.com/sei-protocol/sei-chain/ratelimiter" +) + +var errRateLimitBodyTooLarge = errors.New("request body too large") + +type rateLimitMiddleware struct { + inner http.Handler + gate *RateLimitGate +} + +// NewRateLimitMiddleware wraps inner with CometBFT RPC HTTP rate-limit admission. +// When gate is nil or disabled, inner is returned unchanged. +func NewRateLimitMiddleware(inner http.Handler, gate *RateLimitGate) http.Handler { + if gate == nil || !gate.enabled { + return inner + } + return &rateLimitMiddleware{inner: inner, gate: gate} +} + +func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if isCometBFTRateLimitExemptRequest(r) { + m.inner.ServeHTTP(w, r) + return + } + + ip := m.gate.registry.IPFromHTTPRequest(r) + if isCometBFTURIRPCRequest(r) { + allowed, rejectMethod := m.gate.CheckURI(r.Context(), ip, r.URL.Path) + if !allowed { + if rejectMethod != "" { + logger.Debug("rate limit rejected URI request", "ip", ip, "method", rejectMethod, "plane", m.gate.plane) + } + http.Error(w, "too many requests", http.StatusTooManyRequests) + return + } + m.inner.ServeHTTP(w, r) + return + } + + body, err := readRateLimitBoundedBody(r.Body, m.gate.maxBodyBytes) + if err != nil { + if isRateLimitRequestBodyTooLarge(err) { + m.rejectAdmission(r.Context(), w, ip, http.StatusRequestEntityTooLarge, "request body too large") + return + } + m.rejectAdmission(r.Context(), w, ip, http.StatusBadRequest, "bad request") + return + } + r.Body = io.NopCloser(bytes.NewReader(body)) + + allowed, rejectMethod, checkErr := m.gate.CheckPOST(r.Context(), ip, bytes.NewReader(body)) + if checkErr != nil { + if ratelimiter.IsBodyTooLarge(checkErr) { + m.rejectAdmission(r.Context(), w, ip, http.StatusRequestEntityTooLarge, "request body too large") + return + } + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if !allowed { + logger.Debug("rate limit rejected POST request", "ip", ip, "method", rejectMethod, "plane", m.gate.plane) + http.Error(w, "too many requests", http.StatusTooManyRequests) + return + } + + m.inner.ServeHTTP(w, r) +} + +func (m *rateLimitMiddleware) rejectAdmission(ctx context.Context, w http.ResponseWriter, ip string, status int, msg string) { + if m.gate.chargeAdmissionRejection(ctx, ip) { + http.Error(w, "too many requests", http.StatusTooManyRequests) + return + } + http.Error(w, msg, status) +} + +// isCometBFTRateLimitExemptRequest reports requests that should bypass the gate. +func isCometBFTRateLimitExemptRequest(r *http.Request) bool { + return r.Method == http.MethodOptions +} + +// isCometBFTURIRPCRequest reports REST-style CometBFT RPC routes (GET /status, etc.). +func isCometBFTURIRPCRequest(r *http.Request) bool { + switch r.Method { + case http.MethodGet, http.MethodHead: + return r.URL.Path != "/" && r.URL.Path != "" + default: + return false + } +} + +func readRateLimitBoundedBody(body io.ReadCloser, maxBytes int64) ([]byte, error) { + if body == nil { + return nil, errors.New("missing request body") + } + defer func() { + _, _ = io.Copy(io.Discard, body) + _ = body.Close() + }() + + lr := &io.LimitedReader{R: body, N: maxBytes + 1} + buf, err := io.ReadAll(lr) + if err != nil { + return nil, err + } + if int64(len(buf)) > maxBytes { + return nil, errRateLimitBodyTooLarge + } + return buf, nil +} + +func isRateLimitRequestBodyTooLarge(err error) bool { + if errors.Is(err, errRateLimitBodyTooLarge) { + return true + } + var maxErr *http.MaxBytesError + return errors.As(err, &maxErr) +} diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go new file mode 100644 index 0000000000..74a2be3b4a --- /dev/null +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go @@ -0,0 +1,255 @@ +package server + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/sei-protocol/sei-chain/ratelimiter" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" +) + +func mustCometBFTRateLimitRegistry(t *testing.T, rps float64, burst int) *ratelimiter.Registry { + t.Helper() + reg, err := ratelimiter.New(ratelimiter.Config{RPS: rps, Burst: burst}) + require.NoError(t, err) + return reg +} + +func TestRateLimitMiddleware_POST_AllowsUnderLimit(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 100, 10) + gate := NewRateLimitGate(reg, 0, true) + + called := false + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.JSONEq(t, `{"jsonrpc":"2.0","id":1,"method":"status","params":[]}`, string(body)) + w.WriteHeader(http.StatusOK) + }) + + h := NewRateLimitMiddleware(inner, gate) + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader( + `{"jsonrpc":"2.0","id":1,"method":"status","params":[]}`, + )) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.True(t, called) +} + +func TestRateLimitMiddleware_POST_RejectsAfterBurst(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true) + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h := NewRateLimitMiddleware(inner, gate) + + body := `{"jsonrpc":"2.0","id":1,"method":"status","params":[]}` + req1 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req1.RemoteAddr = "203.0.113.1:1234" + rec1 := httptest.NewRecorder() + h.ServeHTTP(rec1, req1) + require.Equal(t, http.StatusOK, rec1.Code) + + req2 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req2.RemoteAddr = "203.0.113.1:1234" + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + require.Equal(t, http.StatusTooManyRequests, rec2.Code) +} + +func TestRateLimitMiddleware_POST_PerIPIsolation(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true) + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + h := NewRateLimitMiddleware(inner, gate) + body := `{"jsonrpc":"2.0","id":1,"method":"status","params":[]}` + + reqA := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + reqA.RemoteAddr = "203.0.113.1:1" + recA := httptest.NewRecorder() + h.ServeHTTP(recA, reqA) + require.Equal(t, http.StatusOK, recA.Code) + + reqA2 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + reqA2.RemoteAddr = "203.0.113.1:1" + recA2 := httptest.NewRecorder() + h.ServeHTTP(recA2, reqA2) + require.Equal(t, http.StatusTooManyRequests, recA2.Code) + + reqB := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + reqB.RemoteAddr = "203.0.113.2:1" + recB := httptest.NewRecorder() + h.ServeHTTP(recB, reqB) + require.Equal(t, http.StatusOK, recB.Code) +} + +func TestRateLimitMiddleware_POST_BatchCountsAllMethods(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 0.001, 2) + gate := NewRateLimitGate(reg, 0, true) + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + h := NewRateLimitMiddleware(inner, gate) + + batch := `[{"method":"status","id":1},{"method":"block","id":2}]` + req1 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(batch)) + req1.RemoteAddr = "10.0.0.5:1" + rec1 := httptest.NewRecorder() + h.ServeHTTP(rec1, req1) + require.Equal(t, http.StatusOK, rec1.Code) + + req2 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader( + `{"jsonrpc":"2.0","id":1,"method":"status","params":[]}`, + )) + req2.RemoteAddr = "10.0.0.5:1" + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + require.Equal(t, http.StatusTooManyRequests, rec2.Code) +} + +func TestRateLimitMiddleware_GET_ExtractsMethodFromPath(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true) + called := false + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + require.Equal(t, "/status", r.URL.Path) + w.WriteHeader(http.StatusOK) + }) + h := NewRateLimitMiddleware(inner, gate) + + req1 := httptest.NewRequest(http.MethodGet, "/status", nil) + req1.RemoteAddr = "198.51.100.7:1" + rec1 := httptest.NewRecorder() + h.ServeHTTP(rec1, req1) + require.Equal(t, http.StatusOK, rec1.Code) + require.True(t, called) + + req2 := httptest.NewRequest(http.MethodGet, "/status", nil) + req2.RemoteAddr = "198.51.100.7:1" + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + require.Equal(t, http.StatusTooManyRequests, rec2.Code) +} + +func TestRateLimitMiddleware_GET_PerIPIsolation(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true) + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + h := NewRateLimitMiddleware(inner, gate) + + reqA := httptest.NewRequest(http.MethodGet, "/block", nil) + reqA.RemoteAddr = "203.0.113.10:1" + recA := httptest.NewRecorder() + h.ServeHTTP(recA, reqA) + require.Equal(t, http.StatusOK, recA.Code) + + reqA2 := httptest.NewRequest(http.MethodGet, "/block", nil) + reqA2.RemoteAddr = "203.0.113.10:1" + recA2 := httptest.NewRecorder() + h.ServeHTTP(recA2, reqA2) + require.Equal(t, http.StatusTooManyRequests, recA2.Code) + + reqB := httptest.NewRequest(http.MethodGet, "/block", nil) + reqB.RemoteAddr = "203.0.113.11:1" + recB := httptest.NewRecorder() + h.ServeHTTP(recB, reqB) + require.Equal(t, http.StatusOK, recB.Code) +} + +func TestRateLimitMiddleware_DisabledBypassesGate(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, false) + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + h := NewRateLimitMiddleware(inner, gate) + + body := `{"jsonrpc":"2.0","id":1,"method":"status","params":[]}` + for i := 0; i < 3; i++ { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.RemoteAddr = "203.0.113.1:1" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + } +} + +func TestRateLimitMiddleware_OPTIONSExempt(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true) + called := false + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + h := NewRateLimitMiddleware(inner, gate) + + for i := 0; i < 3; i++ { + req := httptest.NewRequest(http.MethodOptions, "/status", nil) + req.RemoteAddr = "203.0.113.1:1" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + } + require.True(t, called) +} + +func TestRateLimitMiddleware_POST_RejectionEmitsMetric(t *testing.T) { + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + prev := otel.GetMeterProvider() + otel.SetMeterProvider(mp) + t.Cleanup(func() { otel.SetMeterProvider(prev) }) + + reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true) + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + h := NewRateLimitMiddleware(inner, gate) + + body := `{"jsonrpc":"2.0","id":1,"method":"status","params":[]}` + req1 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req1.RemoteAddr = "203.0.113.50:1" + rec1 := httptest.NewRecorder() + h.ServeHTTP(rec1, req1) + require.Equal(t, http.StatusOK, rec1.Code) + + req2 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req2.RemoteAddr = "203.0.113.50:1" + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + require.Equal(t, http.StatusTooManyRequests, rec2.Code) + + var rm metricdata.ResourceMetrics + require.NoError(t, reader.Collect(t.Context(), &rm)) + found := false + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + if m.Name != "rpc_rate_limit_rejected_total" { + continue + } + sum := m.Data.(metricdata.Sum[int64]) + for _, dp := range sum.DataPoints { + plane := attributeValue(dp.Attributes, "plane") + if plane != cometbftRateLimitPlane { + continue + } + require.Equal(t, int64(1), dp.Value) + found = true + } + } + } + require.True(t, found, "expected rpc_rate_limit_rejected_total{plane=cometbft}") +} + +func attributeValue(set attribute.Set, key string) string { + v, _ := set.Value(attribute.Key(key)) + return v.AsString() +} From 1d9d63ea0cc160197c47395a462efd3b2dbfc3cf Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Thu, 13 Aug 2026 12:59:55 +0200 Subject: [PATCH 02/21] Turned off Comet BFT rate limiter by default --- sei-tendermint/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sei-tendermint/config/config.go b/sei-tendermint/config/config.go index 134ec155ee..acfe59ab5d 100644 --- a/sei-tendermint/config/config.go +++ b/sei-tendermint/config/config.go @@ -597,7 +597,7 @@ func DefaultRPCConfig() *RPCConfig { IPRateLimitRPS: 200, IPRateLimitBurst: 400, - RateLimitingEnabled: true, + RateLimitingEnabled: false, TrustedProxyCIDRs: nil, } } From a59e2ec18c90acadba14d7dacede83c0f793f19b Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Thu, 13 Aug 2026 13:12:43 +0200 Subject: [PATCH 03/21] Fixed empty catalogue issue --- .../jsonrpc/server/rate_limit_middleware.go | 19 ++++++- .../server/rate_limit_middleware_test.go | 49 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go index 9dd5d7ca77..1d5342e41c 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go @@ -85,7 +85,24 @@ func (m *rateLimitMiddleware) rejectAdmission(ctx context.Context, w http.Respon // isCometBFTRateLimitExemptRequest reports requests that should bypass the gate. func isCometBFTRateLimitExemptRequest(r *http.Request) bool { - return r.Method == http.MethodOptions + if r.Method == http.MethodOptions { + return true + } + return isCometBFTMethodCatalogRequest(r) +} + +// isCometBFTMethodCatalogRequest reports browser/catalog probes to / with no body. +// CometBFT's JSON-RPC handler serves the method list page for these requests. +func isCometBFTMethodCatalogRequest(r *http.Request) bool { + if r.URL.Path != "/" && r.URL.Path != "" { + return false + } + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodPost: + return r.ContentLength == 0 + default: + return false + } } // isCometBFTURIRPCRequest reports REST-style CometBFT RPC routes (GET /status, etc.). diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go index 74a2be3b4a..c2d213baf6 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go @@ -182,6 +182,55 @@ func TestRateLimitMiddleware_DisabledBypassesGate(t *testing.T) { } } +func TestRateLimitMiddleware_MethodCatalogPassthrough(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true) + called := false + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + h := NewRateLimitMiddleware(inner, gate) + + for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodPost} { + t.Run(method, func(t *testing.T) { + called = false + for i := 0; i < 3; i++ { + req := httptest.NewRequest(method, "/", nil) + req.RemoteAddr = "203.0.113.1:1" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code, "iteration %d", i) + } + require.True(t, called) + }) + } +} + +func TestRateLimitMiddleware_GETRootWithBodyRateLimited(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true) + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h := NewRateLimitMiddleware(inner, gate) + + body := `{"jsonrpc":"2.0","id":1,"method":"status","params":[]}` + remote := "203.0.113.99:1" + + req1 := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(body)) + req1.RemoteAddr = remote + rec1 := httptest.NewRecorder() + h.ServeHTTP(rec1, req1) + require.Equal(t, http.StatusOK, rec1.Code) + + req2 := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(body)) + req2.RemoteAddr = remote + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + require.Equal(t, http.StatusTooManyRequests, rec2.Code) +} + func TestRateLimitMiddleware_OPTIONSExempt(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) gate := NewRateLimitGate(reg, 0, true) From 11b2a8cc7a1d58fb9bac7dab837dec980f87290e Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Thu, 13 Aug 2026 13:20:17 +0200 Subject: [PATCH 04/21] Harden CometBFT RPC rate limiter config and admission edge cases. Use kebab-case [rpc] keys so viper reads operator config.toml, align CheckURI parse failures with CheckPOST (400 vs 429), and stop inspect from panicking on invalid trusted-proxy CIDRs when rate limiting is enabled. Co-authored-by: Cursor --- sei-tendermint/config/config.go | 16 ++++---- sei-tendermint/config/config_test.go | 16 ++++++++ sei-tendermint/config/toml.go | 24 ++++++------ sei-tendermint/internal/inspect/rpc/rpc.go | 25 ++++++------ .../internal/inspect/rpc/rpc_test.go | 25 ++++++++++++ sei-tendermint/internal/rpc/core/env.go | 21 +++++----- .../rpc/jsonrpc/server/rate_limit.go | 17 ++++---- .../jsonrpc/server/rate_limit_middleware.go | 6 ++- .../rpc/jsonrpc/server/rate_limit_test.go | 39 +++++++++++++++++++ 9 files changed, 139 insertions(+), 50 deletions(-) create mode 100644 sei-tendermint/internal/inspect/rpc/rpc_test.go create mode 100644 sei-tendermint/rpc/jsonrpc/server/rate_limit_test.go diff --git a/sei-tendermint/config/config.go b/sei-tendermint/config/config.go index acfe59ab5d..e7f2d7a657 100644 --- a/sei-tendermint/config/config.go +++ b/sei-tendermint/config/config.go @@ -539,26 +539,26 @@ type RPCConfig struct { // IPRateLimitRPS is the per-IP sustained request rate in requests/second for // CometBFT RPC HTTP (:26657). Zero disables the token bucket (no HTTP 429 - // rejections). When rate_limiting_enabled is true, the admission middleware + // rejections). When rate-limiting-enabled is true, the admission middleware // still runs: bodies are parsed and oversize/malformed requests are rejected // before dispatch. - IPRateLimitRPS float64 `mapstructure:"ip_rate_limit_rps"` + IPRateLimitRPS float64 `mapstructure:"ip-rate-limit-rps"` // IPRateLimitBurst is the maximum per-IP burst size. Zero disables the token - // bucket (same effect as ip_rate_limit_rps = 0) and does not bypass the - // admission middleware when rate_limiting_enabled is true. Should be at least + // bucket (same effect as ip-rate-limit-rps = 0) and does not bypass the + // admission middleware when rate-limiting-enabled is true. Should be at least // the JSON-RPC batch size limit (10) because the rate limiter charges one // token per batch element. - IPRateLimitBurst int `mapstructure:"ip_rate_limit_burst"` + IPRateLimitBurst int `mapstructure:"ip-rate-limit-burst"` // RateLimitingEnabled is the master switch for the rate-limit admission // middleware on the CometBFT RPC HTTP plane. When false, requests bypass // method extraction and all rejections from that layer (HTTP 400/413/429). - RateLimitingEnabled bool `mapstructure:"rate_limiting_enabled"` + RateLimitingEnabled bool `mapstructure:"rate-limiting-enabled"` // TrustedProxyCIDRs lists CIDRs whose X-Forwarded-For headers are trusted when // resolving the client IP for rate limiting. Empty means trust no proxy. - TrustedProxyCIDRs []string `mapstructure:"trusted_proxy_cidrs"` + TrustedProxyCIDRs []string `mapstructure:"trusted-proxy-cidrs"` } // DefaultRPCConfig returns a default configuration for the RPC server @@ -658,7 +658,7 @@ func (cfg *RPCConfig) ValidateBasic() error { return errors.New("max-search-scan-budget can't be negative") } if cfg.RateLimitingEnabled && cfg.IPRateLimitBurst > 0 && cfg.IPRateLimitBurst < 10 { - return fmt.Errorf("ip_rate_limit_burst (%d) must be >= 10: the rate limiter charges one token per batch element", + return fmt.Errorf("ip-rate-limit-burst (%d) must be >= 10: the rate limiter charges one token per batch element", cfg.IPRateLimitBurst) } return nil diff --git a/sei-tendermint/config/config_test.go b/sei-tendermint/config/config_test.go index 05c9aadec6..6b879a6f05 100644 --- a/sei-tendermint/config/config_test.go +++ b/sei-tendermint/config/config_test.go @@ -324,3 +324,19 @@ func TestWalFile_BothExist_LegacyWins(t *testing.T) { assert.Equal(t, expected, cfg.WalFile(), "legacy should win when both locations exist") } + +func TestRPCRateLimitKeysKebabCase(t *testing.T) { + const body = ` +[rpc] +ip-rate-limit-rps = 42.5 +ip-rate-limit-burst = 50 +rate-limiting-enabled = true +trusted-proxy-cidrs = ["10.0.0.0/8"] +` + conf, err := unmarshalConfigTOML(t, body) + require.NoError(t, err) + require.Equal(t, 42.5, conf.RPC.IPRateLimitRPS) + require.Equal(t, 50, conf.RPC.IPRateLimitBurst) + require.True(t, conf.RPC.RateLimitingEnabled) + require.Equal(t, []string{"10.0.0.0/8"}, conf.RPC.TrustedProxyCIDRs) +} diff --git a/sei-tendermint/config/toml.go b/sei-tendermint/config/toml.go index d40e1f2b1e..02a11f30cf 100644 --- a/sei-tendermint/config/toml.go +++ b/sei-tendermint/config/toml.go @@ -294,27 +294,27 @@ max-tx-search-results = {{ .RPC.MaxTxSearchResults }} # accumulate. Set to 0 to disable the cap (not recommended on public nodes). max-search-scan-budget = {{ .RPC.MaxSearchScanBudget }} -# ip_rate_limit_rps is the per-IP sustained request rate in requests/second for +# ip-rate-limit-rps is the per-IP sustained request rate in requests/second for # CometBFT RPC HTTP (:26657). Zero disables the token bucket (no HTTP 429 -# rejections). When rate_limiting_enabled is true, the admission middleware still +# rejections). When rate-limiting-enabled is true, the admission middleware still # runs: bodies are parsed and oversize/malformed requests are rejected before dispatch. -ip_rate_limit_rps = {{ .RPC.IPRateLimitRPS }} +ip-rate-limit-rps = {{ .RPC.IPRateLimitRPS }} -# ip_rate_limit_burst is the maximum per-IP burst above the sustained rate. -# Zero disables the token bucket (same effect as ip_rate_limit_rps = 0) and does -# not bypass the admission middleware when rate_limiting_enabled is true. Must be -# at least 10 when both are positive and rate_limiting_enabled is true because the +# ip-rate-limit-burst is the maximum per-IP burst above the sustained rate. +# Zero disables the token bucket (same effect as ip-rate-limit-rps = 0) and does +# not bypass the admission middleware when rate-limiting-enabled is true. Must be +# at least 10 when both are positive and rate-limiting-enabled is true because the # rate limiter charges one token per JSON-RPC batch element. -ip_rate_limit_burst = {{ .RPC.IPRateLimitBurst }} +ip-rate-limit-burst = {{ .RPC.IPRateLimitBurst }} -# rate_limiting_enabled is the master switch for the rate-limit admission +# rate-limiting-enabled is the master switch for the rate-limit admission # middleware on the CometBFT RPC HTTP plane. When false, requests bypass method # extraction and all rejections from that layer (HTTP 400/413/429). -rate_limiting_enabled = {{ .RPC.RateLimitingEnabled }} +rate-limiting-enabled = {{ .RPC.RateLimitingEnabled }} -# trusted_proxy_cidrs lists CIDRs whose X-Forwarded-For headers are trusted when +# trusted-proxy-cidrs lists CIDRs whose X-Forwarded-For headers are trusted when # resolving the client IP for rate limiting. Empty means trust no proxy. -trusted_proxy_cidrs = [{{ range .RPC.TrustedProxyCIDRs }}{{ printf "%q, " . }}{{end}}] +trusted-proxy-cidrs = [{{ range .RPC.TrustedProxyCIDRs }}{{ printf "%q, " . }}{{end}}] ####################################################################### ### P2P Configuration Options ### diff --git a/sei-tendermint/internal/inspect/rpc/rpc.go b/sei-tendermint/internal/inspect/rpc/rpc.go index 03fc8389ce..7d2219bd0d 100644 --- a/sei-tendermint/internal/inspect/rpc/rpc.go +++ b/sei-tendermint/internal/inspect/rpc/rpc.go @@ -2,7 +2,6 @@ package rpc import ( "context" - "fmt" "net/http" "github.com/rs/cors" @@ -76,18 +75,20 @@ func Handler(rpcConfig *config.RPCConfig, routes core.RoutesMap) http.Handler { if rpcConfig.IsCorsEnabled() { rootHandler = addCORSHandler(rpcConfig, mux) } - rateLimitRegistry, err := ratelimiter.New(rpcConfig.RateLimiterConfig()) - if err != nil { - panic(fmt.Errorf("inspect rpc rate limiter: %w", err)) + var rateLimitGate *server.RateLimitGate + if rpcConfig.RateLimitingEnabled { + rateLimitRegistry, err := ratelimiter.New(rpcConfig.RateLimiterConfig()) + if err != nil { + logger.Error("RPC rate limiter disabled: invalid configuration", "err", err) + } else { + rateLimitGate = server.NewRateLimitGate( + rateLimitRegistry, + rpcConfig.MaxBodyBytes, + true, + ) + } } - rootHandler = server.NewRateLimitMiddleware( - rootHandler, - server.NewRateLimitGate( - rateLimitRegistry, - rpcConfig.MaxBodyBytes, - rpcConfig.RateLimitingEnabled, - ), - ) + rootHandler = server.NewRateLimitMiddleware(rootHandler, rateLimitGate) return rootHandler } diff --git a/sei-tendermint/internal/inspect/rpc/rpc_test.go b/sei-tendermint/internal/inspect/rpc/rpc_test.go new file mode 100644 index 0000000000..84f2122f1f --- /dev/null +++ b/sei-tendermint/internal/inspect/rpc/rpc_test.go @@ -0,0 +1,25 @@ +package rpc + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-tendermint/config" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/rpc/core" +) + +func TestHandler_InvalidTrustedProxyCIDRsDoesNotPanic(t *testing.T) { + cfg := config.DefaultRPCConfig() + cfg.RateLimitingEnabled = true + cfg.TrustedProxyCIDRs = []string{"not-a-cidr"} + + require.NotPanics(t, func() { + h := Handler(cfg, core.RoutesMap{}) + req := httptest.NewRequest(http.MethodGet, "/status", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + }) +} diff --git a/sei-tendermint/internal/rpc/core/env.go b/sei-tendermint/internal/rpc/core/env.go index ae28f48205..238815779c 100644 --- a/sei-tendermint/internal/rpc/core/env.go +++ b/sei-tendermint/internal/rpc/core/env.go @@ -363,18 +363,19 @@ func (env *Environment) StartService(ctx context.Context, conf *config.Config) ( }) rootHandler = corsMiddleware.Handler(mux) } - rateLimitRegistry, err := ratelimiter.New(conf.RPC.RateLimiterConfig()) - if err != nil { - return nil, fmt.Errorf("rpc rate limiter: %w", err) - } - rootHandler = rpcserver.NewRateLimitMiddleware( - rootHandler, - rpcserver.NewRateLimitGate( + var rateLimitGate *rpcserver.RateLimitGate + if conf.RPC.RateLimitingEnabled { + rateLimitRegistry, err := ratelimiter.New(conf.RPC.RateLimiterConfig()) + if err != nil { + return nil, fmt.Errorf("rpc rate limiter: %w", err) + } + rateLimitGate = rpcserver.NewRateLimitGate( rateLimitRegistry, conf.RPC.MaxBodyBytes, - conf.RPC.RateLimitingEnabled, - ), - ) + true, + ) + } + rootHandler = rpcserver.NewRateLimitMiddleware(rootHandler, rateLimitGate) if conf.RPC.IsTLSEnabled() { go func() { if err := rpcserver.ServeTLS( diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit.go index 588c625994..7c2094b9de 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit.go @@ -2,6 +2,7 @@ package server import ( "context" + "errors" "io" "math" "strings" @@ -11,6 +12,8 @@ import ( const cometbftRateLimitPlane = "cometbft" +var errInvalidURIMethod = errors.New("invalid URI method") + // RateLimitGate applies per-IP token-bucket rate limiting for CometBFT RPC HTTP // requests. POST JSON-RPC bodies are parsed with MethodParser before full decode; // GET URI routes are accounted by path-derived method names. @@ -74,19 +77,19 @@ func (g *RateLimitGate) CheckPOST(ctx context.Context, ip string, body io.Reader } // CheckURI applies per-IP rate limits for REST-style GET/HEAD RPC routes. -func (g *RateLimitGate) CheckURI(ctx context.Context, ip, method string) (allowed bool, rejectMethod string) { +func (g *RateLimitGate) CheckURI(ctx context.Context, ip, path string) (allowed bool, rejectMethod string, err error) { if !g.enabled { - return true, "" + return true, "", nil } - method = strings.TrimPrefix(method, "/") + method := strings.TrimPrefix(path, "/") if method == "" { if !g.registry.Allow(ctx, ip, g.plane, ratelimiter.MethodInvalid) { - return false, ratelimiter.MethodInvalid + return false, ratelimiter.MethodInvalid, nil } - return false, "" + return false, "", errInvalidURIMethod } if !g.registry.Allow(ctx, ip, g.plane, method) { - return false, method + return false, method, nil } - return true, "" + return true, "", nil } diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go index 1d5342e41c..6643c13fd9 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go @@ -34,7 +34,11 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) ip := m.gate.registry.IPFromHTTPRequest(r) if isCometBFTURIRPCRequest(r) { - allowed, rejectMethod := m.gate.CheckURI(r.Context(), ip, r.URL.Path) + allowed, rejectMethod, checkErr := m.gate.CheckURI(r.Context(), ip, r.URL.Path) + if checkErr != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } if !allowed { if rejectMethod != "" { logger.Debug("rate limit rejected URI request", "ip", ip, "method", rejectMethod, "plane", m.gate.plane) diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_test.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_test.go new file mode 100644 index 0000000000..e77351d728 --- /dev/null +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_test.go @@ -0,0 +1,39 @@ +package server + +import ( + "testing" + + "github.com/sei-protocol/sei-chain/ratelimiter" + "github.com/stretchr/testify/require" +) + +func TestRateLimitGate_CheckURI_InvalidPathReturns400(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 100, 10) + gate := NewRateLimitGate(reg, 0, true) + + allowed, rejectMethod, err := gate.CheckURI(t.Context(), "203.0.113.1", "/") + require.False(t, allowed) + require.Empty(t, rejectMethod) + require.ErrorIs(t, err, errInvalidURIMethod) +} + +func TestRateLimitGate_CheckURI_InvalidPathReturns429WhenBucketExhausted(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true) + ip := "203.0.113.50" + + allowed, rejectMethod, err := gate.CheckURI(t.Context(), ip, "/status") + require.True(t, allowed) + require.NoError(t, err) + require.Empty(t, rejectMethod) + + allowed, rejectMethod, err = gate.CheckURI(t.Context(), ip, "/status") + require.False(t, allowed) + require.Equal(t, "status", rejectMethod) + require.NoError(t, err) + + allowed, rejectMethod, err = gate.CheckURI(t.Context(), ip, "/") + require.False(t, allowed) + require.Equal(t, ratelimiter.MethodInvalid, rejectMethod) + require.NoError(t, err) +} From 2e8fcc7e03af495e0c5a4b01037d5be768907934 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Thu, 13 Aug 2026 13:25:48 +0200 Subject: [PATCH 05/21] Return JSON-RPC errors for POST rate-limit rejections and expand tests. POST / admission failures that clients must decode now respond with JSON-RPC error objects (including HTTP 429 on throttle) instead of plain text. Add coverage for oversize bodies, malformed JSON, burst validation, and GET / method catalog passthrough. Co-authored-by: Cursor --- sei-tendermint/config/config_test.go | 9 +++ .../rpc/jsonrpc/server/http_server.go | 19 ++++++ .../jsonrpc/server/rate_limit_middleware.go | 34 +++++++++-- .../server/rate_limit_middleware_test.go | 58 +++++++++++++++++++ 4 files changed, 116 insertions(+), 4 deletions(-) diff --git a/sei-tendermint/config/config_test.go b/sei-tendermint/config/config_test.go index 6b879a6f05..a95f6d3c36 100644 --- a/sei-tendermint/config/config_test.go +++ b/sei-tendermint/config/config_test.go @@ -95,6 +95,15 @@ func TestRPCConfigValidateBasic(t *testing.T) { assert.NoError(t, cfg2.ValidateBasic()) cfg2.TimeoutWrite = 0 // 0 disables; constraint does not apply assert.NoError(t, cfg2.ValidateBasic()) + + cfg3 := TestRPCConfig() + cfg3.RateLimitingEnabled = true + cfg3.IPRateLimitBurst = 5 + assert.Error(t, cfg3.ValidateBasic()) + cfg3.IPRateLimitBurst = 10 + assert.NoError(t, cfg3.ValidateBasic()) + cfg3.IPRateLimitBurst = 0 + assert.NoError(t, cfg3.ValidateBasic()) } func TestMempoolConfigValidateBasic(t *testing.T) { diff --git a/sei-tendermint/rpc/jsonrpc/server/http_server.go b/sei-tendermint/rpc/jsonrpc/server/http_server.go index c73b00a465..766c6be448 100644 --- a/sei-tendermint/rpc/jsonrpc/server/http_server.go +++ b/sei-tendermint/rpc/jsonrpc/server/http_server.go @@ -196,6 +196,25 @@ func writeRPCResponse(w http.ResponseWriter, rsps ...rpctypes.RPCResponse) { _, _ = w.Write(body) } +// writeJSONRPCErrorWithStatus writes a JSON-RPC 2.0 error object for POST / +// admission rejections. reqBody is best-effort parsed for the response id. +func writeJSONRPCErrorWithStatus(w http.ResponseWriter, reqBody []byte, httpStatus int, code rpctypes.ErrorCode, format string, args ...interface{}) { + var req rpctypes.RPCRequest + if len(reqBody) > 0 { + _ = json.Unmarshal(reqBody, &req) + } + resp := req.MakeErrorf(code, format, args...) + body, err := json.Marshal(resp) + if err != nil { + logger.Error("Error encoding RPC response", "err", err) + writeError(w, http.StatusInternalServerError, err) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(httpStatus) + _, _ = w.Write(body) +} + //----------------------------------------------------------------------------- // recoverAndLogHandler wraps an HTTP handler, adding error logging. If the diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go index 6643c13fd9..26b8d1c3bc 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go @@ -8,6 +8,7 @@ import ( "net/http" "github.com/sei-protocol/sei-chain/ratelimiter" + rpctypes "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/jsonrpc/types" ) var errRateLimitBodyTooLarge = errors.New("request body too large") @@ -19,6 +20,10 @@ type rateLimitMiddleware struct { // NewRateLimitMiddleware wraps inner with CometBFT RPC HTTP rate-limit admission. // When gate is nil or disabled, inner is returned unchanged. +// +// POST / JSON-RPC bodies and GET/HEAD URI routes (including the WebSocket +// handshake GET /websocket, charged as method "websocket") are limited. +// WebSocket frames after upgrade are not covered by this middleware. func NewRateLimitMiddleware(inner http.Handler, gate *RateLimitGate) http.Handler { if gate == nil || !gate.enabled { return inner @@ -53,10 +58,10 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) body, err := readRateLimitBoundedBody(r.Body, m.gate.maxBodyBytes) if err != nil { if isRateLimitRequestBodyTooLarge(err) { - m.rejectAdmission(r.Context(), w, ip, http.StatusRequestEntityTooLarge, "request body too large") + m.rejectAdmission(r.Context(), w, r, ip, body, http.StatusRequestEntityTooLarge, rpctypes.CodeInvalidRequest, "request body too large") return } - m.rejectAdmission(r.Context(), w, ip, http.StatusBadRequest, "bad request") + m.rejectAdmission(r.Context(), w, r, ip, body, http.StatusBadRequest, rpctypes.CodeInvalidRequest, "bad request") return } r.Body = io.NopCloser(bytes.NewReader(body)) @@ -64,7 +69,11 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) allowed, rejectMethod, checkErr := m.gate.CheckPOST(r.Context(), ip, bytes.NewReader(body)) if checkErr != nil { if ratelimiter.IsBodyTooLarge(checkErr) { - m.rejectAdmission(r.Context(), w, ip, http.StatusRequestEntityTooLarge, "request body too large") + m.rejectAdmission(r.Context(), w, r, ip, body, http.StatusRequestEntityTooLarge, rpctypes.CodeInvalidRequest, "request body too large") + return + } + if isCometBFTPostJSONRPCRequest(r) { + writeJSONRPCErrorWithStatus(w, body, http.StatusBadRequest, rpctypes.CodeParseError, "decoding request: %v", checkErr) return } http.Error(w, "bad request", http.StatusBadRequest) @@ -72,6 +81,10 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) } if !allowed { logger.Debug("rate limit rejected POST request", "ip", ip, "method", rejectMethod, "plane", m.gate.plane) + if isCometBFTPostJSONRPCRequest(r) { + writeJSONRPCErrorWithStatus(w, body, http.StatusTooManyRequests, rpctypes.CodeInternalError, "too many requests") + return + } http.Error(w, "too many requests", http.StatusTooManyRequests) return } @@ -79,11 +92,19 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) m.inner.ServeHTTP(w, r) } -func (m *rateLimitMiddleware) rejectAdmission(ctx context.Context, w http.ResponseWriter, ip string, status int, msg string) { +func (m *rateLimitMiddleware) rejectAdmission(ctx context.Context, w http.ResponseWriter, r *http.Request, ip string, body []byte, status int, code rpctypes.ErrorCode, msg string) { if m.gate.chargeAdmissionRejection(ctx, ip) { + if isCometBFTPostJSONRPCRequest(r) { + writeJSONRPCErrorWithStatus(w, body, http.StatusTooManyRequests, rpctypes.CodeInternalError, "too many requests") + return + } http.Error(w, "too many requests", http.StatusTooManyRequests) return } + if isCometBFTPostJSONRPCRequest(r) && status != http.StatusRequestEntityTooLarge { + writeJSONRPCErrorWithStatus(w, body, status, code, "%s", msg) + return + } http.Error(w, msg, status) } @@ -109,6 +130,11 @@ func isCometBFTMethodCatalogRequest(r *http.Request) bool { } } +// isCometBFTPostJSONRPCRequest reports POST requests to the JSON-RPC root path. +func isCometBFTPostJSONRPCRequest(r *http.Request) bool { + return r.Method == http.MethodPost && (r.URL.Path == "/" || r.URL.Path == "") +} + // isCometBFTURIRPCRequest reports REST-style CometBFT RPC routes (GET /status, etc.). func isCometBFTURIRPCRequest(r *http.Request) bool { switch r.Method { diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go index c2d213baf6..397f5edf9b 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go @@ -1,6 +1,7 @@ package server import ( + "encoding/json" "io" "net/http" "net/http/httptest" @@ -8,6 +9,7 @@ import ( "testing" "github.com/sei-protocol/sei-chain/ratelimiter" + rpctypes "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/jsonrpc/types" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -66,6 +68,7 @@ func TestRateLimitMiddleware_POST_RejectsAfterBurst(t *testing.T) { rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, req2) require.Equal(t, http.StatusTooManyRequests, rec2.Code) + requireJSONRPCError(t, rec2.Body.Bytes(), int(rpctypes.CodeInternalError), "too many requests") } func TestRateLimitMiddleware_POST_PerIPIsolation(t *testing.T) { @@ -298,6 +301,61 @@ func TestRateLimitMiddleware_POST_RejectionEmitsMetric(t *testing.T) { require.True(t, found, "expected rpc_rate_limit_rejected_total{plane=cometbft}") } +func TestRateLimitMiddleware_POST_OversizeBodyReturns413(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 100, 10) + gate := NewRateLimitGate(reg, 64, true) + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("inner should not be called") + }) + h := NewRateLimitMiddleware(inner, gate) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("x", 100)))) + require.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) + require.Contains(t, rec.Body.String(), "request body too large") +} + +func TestRateLimitMiddleware_POST_MalformedJSONReturns400(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 100, 10) + gate := NewRateLimitGate(reg, 0, true) + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("inner should not be called") + }) + h := NewRateLimitMiddleware(inner, gate) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"method":123,"id":1}`))) + require.Equal(t, http.StatusBadRequest, rec.Code) + + var resp rpctypes.RPCResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.NotNil(t, resp.Error) + require.Equal(t, int(rpctypes.CodeParseError), resp.Error.Code) +} + +func TestRateLimitMiddleware_GETRootServesMethodCatalog(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true) + h := NewRateLimitMiddleware(testMux(), gate) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "203.0.113.1:1" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Header().Get("Content-Type"), "text/html") + require.Contains(t, rec.Body.String(), "Available RPC endpoints") +} + +func requireJSONRPCError(t *testing.T, body []byte, wantCode int, wantSubstring string) { + t.Helper() + var resp rpctypes.RPCResponse + require.NoError(t, json.Unmarshal(body, &resp)) + require.NotNil(t, resp.Error) + require.Equal(t, wantCode, resp.Error.Code) + require.Contains(t, resp.Error.Data, wantSubstring) +} + func attributeValue(set attribute.Set, key string) string { v, _ := set.Value(attribute.Key(key)) return v.AsString() From 47e4f2e79121e8716fb121d29900319815b14662 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 14 Aug 2026 12:55:19 +0200 Subject: [PATCH 06/21] Refactored to use RequestBatchSizeLimit instead of magic number --- sei-tendermint/config/config.go | 11 ++++++----- sei-tendermint/config/config_test.go | 5 +++-- sei-tendermint/config/toml.go | 5 +++-- .../rpc/jsonrpc/server/http_json_handler.go | 4 +--- sei-tendermint/rpc/jsonrpc/types/types.go | 3 +++ 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/sei-tendermint/config/config.go b/sei-tendermint/config/config.go index e7f2d7a657..1584420129 100644 --- a/sei-tendermint/config/config.go +++ b/sei-tendermint/config/config.go @@ -15,6 +15,7 @@ import ( mempoolcfg "github.com/sei-protocol/sei-chain/sei-tendermint/internal/mempool" tmos "github.com/sei-protocol/sei-chain/sei-tendermint/libs/os" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" + rpctypes "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/jsonrpc/types" "github.com/sei-protocol/sei-chain/sei-tendermint/types" ) @@ -547,8 +548,8 @@ type RPCConfig struct { // IPRateLimitBurst is the maximum per-IP burst size. Zero disables the token // bucket (same effect as ip-rate-limit-rps = 0) and does not bypass the // admission middleware when rate-limiting-enabled is true. Should be at least - // the JSON-RPC batch size limit (10) because the rate limiter charges one - // token per batch element. + // the JSON-RPC batch size limit because the rate limiter charges one token + // per batch element. IPRateLimitBurst int `mapstructure:"ip-rate-limit-burst"` // RateLimitingEnabled is the master switch for the rate-limit admission @@ -657,9 +658,9 @@ func (cfg *RPCConfig) ValidateBasic() error { if cfg.MaxSearchScanBudget < 0 { return errors.New("max-search-scan-budget can't be negative") } - if cfg.RateLimitingEnabled && cfg.IPRateLimitBurst > 0 && cfg.IPRateLimitBurst < 10 { - return fmt.Errorf("ip-rate-limit-burst (%d) must be >= 10: the rate limiter charges one token per batch element", - cfg.IPRateLimitBurst) + if cfg.RateLimitingEnabled && cfg.IPRateLimitBurst > 0 && cfg.IPRateLimitBurst < rpctypes.RequestBatchSizeLimit { + return fmt.Errorf("ip-rate-limit-burst (%d) must be >= %d: the rate limiter charges one token per batch element", + cfg.IPRateLimitBurst, rpctypes.RequestBatchSizeLimit) } return nil } diff --git a/sei-tendermint/config/config_test.go b/sei-tendermint/config/config_test.go index a95f6d3c36..bb666d3eb8 100644 --- a/sei-tendermint/config/config_test.go +++ b/sei-tendermint/config/config_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" + rpctypes "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/jsonrpc/types" "github.com/sei-protocol/sei-chain/sei-tendermint/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -98,9 +99,9 @@ func TestRPCConfigValidateBasic(t *testing.T) { cfg3 := TestRPCConfig() cfg3.RateLimitingEnabled = true - cfg3.IPRateLimitBurst = 5 + cfg3.IPRateLimitBurst = rpctypes.RequestBatchSizeLimit - 1 assert.Error(t, cfg3.ValidateBasic()) - cfg3.IPRateLimitBurst = 10 + cfg3.IPRateLimitBurst = rpctypes.RequestBatchSizeLimit assert.NoError(t, cfg3.ValidateBasic()) cfg3.IPRateLimitBurst = 0 assert.NoError(t, cfg3.ValidateBasic()) diff --git a/sei-tendermint/config/toml.go b/sei-tendermint/config/toml.go index 02a11f30cf..ecee22a317 100644 --- a/sei-tendermint/config/toml.go +++ b/sei-tendermint/config/toml.go @@ -303,8 +303,9 @@ ip-rate-limit-rps = {{ .RPC.IPRateLimitRPS }} # ip-rate-limit-burst is the maximum per-IP burst above the sustained rate. # Zero disables the token bucket (same effect as ip-rate-limit-rps = 0) and does # not bypass the admission middleware when rate-limiting-enabled is true. Must be -# at least 10 when both are positive and rate-limiting-enabled is true because the -# rate limiter charges one token per JSON-RPC batch element. +# at least the JSON-RPC batch size limit when both are positive and +# rate-limiting-enabled is true because the rate limiter charges one token per +# JSON-RPC batch element. ip-rate-limit-burst = {{ .RPC.IPRateLimitBurst }} # rate-limiting-enabled is the master switch for the rate-limit admission diff --git a/sei-tendermint/rpc/jsonrpc/server/http_json_handler.go b/sei-tendermint/rpc/jsonrpc/server/http_json_handler.go index aacc76a9f5..95da658a3a 100644 --- a/sei-tendermint/rpc/jsonrpc/server/http_json_handler.go +++ b/sei-tendermint/rpc/jsonrpc/server/http_json_handler.go @@ -13,8 +13,6 @@ import ( // HTTP + JSON handler -const REQUEST_BATCH_SIZE_LIMIT = 10 - // jsonrpc calls grab the given method's function info and runs reflect.Call func makeJSONRPCHandler(funcMap map[string]*RPCFunc) http.HandlerFunc { return func(w http.ResponseWriter, hreq *http.Request) { @@ -41,7 +39,7 @@ func makeJSONRPCHandler(funcMap map[string]*RPCFunc) http.HandlerFunc { } requests, err := parseRequests(b) - if len(requests) > REQUEST_BATCH_SIZE_LIMIT { + if len(requests) > rpctypes.RequestBatchSizeLimit { writeRPCResponse(w, rpctypes.RPCRequest{}.MakeErrorf( rpctypes.CodeParseError, "Batch size limit exceeded.")) return diff --git a/sei-tendermint/rpc/jsonrpc/types/types.go b/sei-tendermint/rpc/jsonrpc/types/types.go index 990dca7ac6..c9349bc93c 100644 --- a/sei-tendermint/rpc/jsonrpc/types/types.go +++ b/sei-tendermint/rpc/jsonrpc/types/types.go @@ -35,6 +35,9 @@ const ( CodeLagIsHighError ErrorCode = -32604 // Lag is too high error ) +// RequestBatchSizeLimit is the maximum number of JSON-RPC requests allowed in one batch. +const RequestBatchSizeLimit = 10 + var errorCodeString = map[ErrorCode]string{ CodeParseError: "Parse error", CodeInvalidRequest: "Invalid request", From 24e706f5c33c97f00c3b9a75bd3ec0728d97294a Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 14 Aug 2026 13:59:32 +0200 Subject: [PATCH 07/21] Added error handling for rate limit construction --- sei-tendermint/internal/inspect/inspect.go | 5 +++- sei-tendermint/internal/inspect/rpc/rpc.go | 24 +++++++++---------- .../internal/inspect/rpc/rpc_test.go | 22 ++++++++++------- 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/sei-tendermint/internal/inspect/inspect.go b/sei-tendermint/internal/inspect/inspect.go index e04c31862f..dbc50e8539 100644 --- a/sei-tendermint/internal/inspect/inspect.go +++ b/sei-tendermint/internal/inspect/inspect.go @@ -101,7 +101,10 @@ func (ins *Inspector) Run(ctx context.Context) error { func startRPCServers(ctx context.Context, cfg *config.RPCConfig, routes rpccore.RoutesMap) error { g, tctx := errgroup.WithContext(ctx) listenAddrs := tmstrings.SplitAndTrimEmpty(cfg.ListenAddress, ",", " ") - rh := rpc.Handler(cfg, routes) + rh, err := rpc.Handler(cfg, routes) + if err != nil { + return err + } for _, listenerAddr := range listenAddrs { server := rpc.Server{ Config: cfg, diff --git a/sei-tendermint/internal/inspect/rpc/rpc.go b/sei-tendermint/internal/inspect/rpc/rpc.go index 7d2219bd0d..3b7732ec82 100644 --- a/sei-tendermint/internal/inspect/rpc/rpc.go +++ b/sei-tendermint/internal/inspect/rpc/rpc.go @@ -2,6 +2,7 @@ package rpc import ( "context" + "fmt" "net/http" "github.com/rs/cors" @@ -51,10 +52,10 @@ func Routes(cfg config.RPCConfig, s state.Store, bs state.BlockStore, es []index } } -// Handler returns the http.Handler configured for use with an Inspector server. Handler -// registers the routes on the http.Handler and also registers the websocket handler -// and the CORS handler if specified by the configuration options. -func Handler(rpcConfig *config.RPCConfig, routes core.RoutesMap) http.Handler { +// Handler returns the Inspector HTTP handler with routes, websocket, CORS, and +// the rate-limit gate when enabled. It returns an error if the rate limiter +// cannot be constructed. +func Handler(rpcConfig *config.RPCConfig, routes core.RoutesMap) (http.Handler, error) { mux := http.NewServeMux() var eventBus eventBusUnsubscriber @@ -79,17 +80,16 @@ func Handler(rpcConfig *config.RPCConfig, routes core.RoutesMap) http.Handler { if rpcConfig.RateLimitingEnabled { rateLimitRegistry, err := ratelimiter.New(rpcConfig.RateLimiterConfig()) if err != nil { - logger.Error("RPC rate limiter disabled: invalid configuration", "err", err) - } else { - rateLimitGate = server.NewRateLimitGate( - rateLimitRegistry, - rpcConfig.MaxBodyBytes, - true, - ) + return nil, fmt.Errorf("rpc rate limiter: %w", err) } + rateLimitGate = server.NewRateLimitGate( + rateLimitRegistry, + rpcConfig.MaxBodyBytes, + true, + ) } rootHandler = server.NewRateLimitMiddleware(rootHandler, rateLimitGate) - return rootHandler + return rootHandler, nil } func addCORSHandler(rpcConfig *config.RPCConfig, h http.Handler) http.Handler { diff --git a/sei-tendermint/internal/inspect/rpc/rpc_test.go b/sei-tendermint/internal/inspect/rpc/rpc_test.go index 84f2122f1f..7e1501a94a 100644 --- a/sei-tendermint/internal/inspect/rpc/rpc_test.go +++ b/sei-tendermint/internal/inspect/rpc/rpc_test.go @@ -1,8 +1,6 @@ package rpc import ( - "net/http" - "net/http/httptest" "testing" "github.com/stretchr/testify/require" @@ -11,15 +9,21 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/internal/rpc/core" ) -func TestHandler_InvalidTrustedProxyCIDRsDoesNotPanic(t *testing.T) { +func TestHandler_InvalidTrustedProxyCIDRs(t *testing.T) { cfg := config.DefaultRPCConfig() cfg.RateLimitingEnabled = true cfg.TrustedProxyCIDRs = []string{"not-a-cidr"} - require.NotPanics(t, func() { - h := Handler(cfg, core.RoutesMap{}) - req := httptest.NewRequest(http.MethodGet, "/status", nil) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - }) + h, err := Handler(cfg, core.RoutesMap{}) + require.Error(t, err) + require.Nil(t, h) +} + +func TestHandler_RateLimitingEnabled(t *testing.T) { + cfg := config.DefaultRPCConfig() + cfg.RateLimitingEnabled = true + + h, err := Handler(cfg, core.RoutesMap{}) + require.NoError(t, err) + require.NotNil(t, h) } From 5368fe04e84fd99f77ee0d3a600a0b674e0bae2d Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 14 Aug 2026 15:43:15 +0200 Subject: [PATCH 08/21] Shared rate limit gate for all listeners --- sei-tendermint/internal/rpc/core/env.go | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/sei-tendermint/internal/rpc/core/env.go b/sei-tendermint/internal/rpc/core/env.go index 238815779c..429c11e36d 100644 --- a/sei-tendermint/internal/rpc/core/env.go +++ b/sei-tendermint/internal/rpc/core/env.go @@ -323,6 +323,19 @@ func (env *Environment) StartService(ctx context.Context, conf *config.Config) ( logger.Info("Event log subscription enabled") } + var rateLimitGate *rpcserver.RateLimitGate + if conf.RPC.RateLimitingEnabled { + rateLimitRegistry, err := ratelimiter.New(conf.RPC.RateLimiterConfig()) + if err != nil { + return nil, fmt.Errorf("rpc rate limiter: %w", err) + } + rateLimitGate = rpcserver.NewRateLimitGate( + rateLimitRegistry, + conf.RPC.MaxBodyBytes, + true, + ) + } + // We may expose the RPC over both TCP and a Unix-domain socket. listeners := make([]net.Listener, len(listenAddrs)) for i, listenAddr := range listenAddrs { @@ -363,18 +376,6 @@ func (env *Environment) StartService(ctx context.Context, conf *config.Config) ( }) rootHandler = corsMiddleware.Handler(mux) } - var rateLimitGate *rpcserver.RateLimitGate - if conf.RPC.RateLimitingEnabled { - rateLimitRegistry, err := ratelimiter.New(conf.RPC.RateLimiterConfig()) - if err != nil { - return nil, fmt.Errorf("rpc rate limiter: %w", err) - } - rateLimitGate = rpcserver.NewRateLimitGate( - rateLimitRegistry, - conf.RPC.MaxBodyBytes, - true, - ) - } rootHandler = rpcserver.NewRateLimitMiddleware(rootHandler, rateLimitGate) if conf.RPC.IsTLSEnabled() { go func() { From edbdc181ed6a929a402efe7f929342a021b9ec6b Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 14 Aug 2026 15:47:12 +0200 Subject: [PATCH 09/21] Fixed CORS handler order --- sei-tendermint/internal/inspect/rpc/rpc.go | 9 ++++----- sei-tendermint/internal/rpc/core/env.go | 5 ++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/sei-tendermint/internal/inspect/rpc/rpc.go b/sei-tendermint/internal/inspect/rpc/rpc.go index 3b7732ec82..f34f89be50 100644 --- a/sei-tendermint/internal/inspect/rpc/rpc.go +++ b/sei-tendermint/internal/inspect/rpc/rpc.go @@ -72,10 +72,6 @@ func Handler(rpcConfig *config.RPCConfig, routes core.RoutesMap) (http.Handler, mux.HandleFunc("/websocket", wm.WebsocketHandler) server.RegisterRPCFuncs(mux, routes) - var rootHandler http.Handler = mux - if rpcConfig.IsCorsEnabled() { - rootHandler = addCORSHandler(rpcConfig, mux) - } var rateLimitGate *server.RateLimitGate if rpcConfig.RateLimitingEnabled { rateLimitRegistry, err := ratelimiter.New(rpcConfig.RateLimiterConfig()) @@ -88,7 +84,10 @@ func Handler(rpcConfig *config.RPCConfig, routes core.RoutesMap) (http.Handler, true, ) } - rootHandler = server.NewRateLimitMiddleware(rootHandler, rateLimitGate) + rootHandler := server.NewRateLimitMiddleware(mux, rateLimitGate) + if rpcConfig.IsCorsEnabled() { + rootHandler = addCORSHandler(rpcConfig, rootHandler) + } return rootHandler, nil } diff --git a/sei-tendermint/internal/rpc/core/env.go b/sei-tendermint/internal/rpc/core/env.go index 429c11e36d..77bdb76d4e 100644 --- a/sei-tendermint/internal/rpc/core/env.go +++ b/sei-tendermint/internal/rpc/core/env.go @@ -367,16 +367,15 @@ func (env *Environment) StartService(ctx context.Context, conf *config.Config) ( return nil, err } - var rootHandler http.Handler = mux + rootHandler := rpcserver.NewRateLimitMiddleware(mux, rateLimitGate) if conf.RPC.IsCorsEnabled() { corsMiddleware := cors.New(cors.Options{ AllowedOrigins: conf.RPC.CORSAllowedOrigins, AllowedMethods: conf.RPC.CORSAllowedMethods, AllowedHeaders: conf.RPC.CORSAllowedHeaders, }) - rootHandler = corsMiddleware.Handler(mux) + rootHandler = corsMiddleware.Handler(rootHandler) } - rootHandler = rpcserver.NewRateLimitMiddleware(rootHandler, rateLimitGate) if conf.RPC.IsTLSEnabled() { go func() { if err := rpcserver.ServeTLS( From c35b819d11dcbf9c6febc3d9ff393169136818ef Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 14 Aug 2026 16:03:15 +0200 Subject: [PATCH 10/21] Preserve unlimited max-body-bytes when CometBFT rate limiting is enabled. Non-positive max-body-bytes must stay unlimited like MaxBytesHandler. Co-authored-by: Cursor --- ratelimiter/doc.go | 5 +++-- ratelimiter/method_parser.go | 15 +++++++++---- ratelimiter/method_parser_test.go | 13 +++++++++-- .../rpc/jsonrpc/server/rate_limit.go | 7 +++--- .../jsonrpc/server/rate_limit_middleware.go | 4 ++++ .../server/rate_limit_middleware_test.go | 22 +++++++++++++++++++ 6 files changed, 55 insertions(+), 11 deletions(-) diff --git a/ratelimiter/doc.go b/ratelimiter/doc.go index 3818531de5..87ce79e037 100644 --- a/ratelimiter/doc.go +++ b/ratelimiter/doc.go @@ -3,8 +3,9 @@ // // # MethodParser fail-closed contract // -// MethodParser.Parse must be used on the entire request body, bounded by -// MaxProbeBytes (see DefaultMaxProbeBytes). Callers must reject the request on +// MethodParser.Parse must be used on the entire request body. When MaxProbeBytes +// is positive the body must fit within that limit (see DefaultMaxProbeBytes for +// a typical cap); non-positive means no parser-imposed limit. Callers must reject on // every returned error — including ErrProbeLimit — with no fallback decode and // no default method bucket. Mapping any error to "admit anyway" defeats method // extraction and allows rate-limit bypass (for example duplicate "method" keys diff --git a/ratelimiter/method_parser.go b/ratelimiter/method_parser.go index 92affa8e68..30d13e6eb0 100644 --- a/ratelimiter/method_parser.go +++ b/ratelimiter/method_parser.go @@ -46,10 +46,10 @@ type MethodParser struct { } // NewMethodParser returns a MethodParser that accepts request bodies of at most -// maxProbeBytes bytes. Non-positive values use DefaultMaxProbeBytes. +// maxProbeBytes bytes. Non-positive values mean unlimited (no parser-imposed cap). func NewMethodParser(maxProbeBytes int64) *MethodParser { if maxProbeBytes <= 0 { - maxProbeBytes = DefaultMaxProbeBytes + maxProbeBytes = 0 } if maxProbeBytes == math.MaxInt64 { // Prevent overflow when constructing the LimitedReader budget (+1). @@ -65,11 +65,18 @@ func NewMethodParser(maxProbeBytes int64) *MethodParser { // Fail-closed contract: callers must reject on every returned error, including // ErrProbeLimit. See the package doc for HTTP/RPC mapping guidance. func (p *MethodParser) Parse(r io.Reader) (methods []string, batch bool, err error) { + if p.maxProbeBytes <= 0 { + dec := json.NewDecoder(r) + return parseMethods(dec, nil) + } // N is maxProbeBytes+1 so that lr.N reaching 0 unambiguously means the body // exceeded the budget. lr := &io.LimitedReader{R: r, N: p.maxProbeBytes + 1} dec := json.NewDecoder(lr) + return parseMethods(dec, lr) +} +func parseMethods(dec *json.Decoder, lr *io.LimitedReader) (methods []string, batch bool, err error) { tok, err := dec.Token() if err != nil { return nil, false, classifyErr(err, lr) @@ -108,7 +115,7 @@ func (p *MethodParser) Parse(r io.Reader) (methods []string, batch bool, err err // trailing non-whitespace data. func expectEOF(dec *json.Decoder, lr *io.LimitedReader) error { if _, err := dec.Token(); err != nil { - if errors.Is(err, io.EOF) && lr.N > 0 { + if errors.Is(err, io.EOF) && (lr == nil || lr.N > 0) { return nil } return err @@ -211,7 +218,7 @@ func classifyErr(err error, lr *io.LimitedReader) error { if err == nil { return nil } - if (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) && lr.N <= 0 { + if lr != nil && (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) && lr.N <= 0 { return ErrProbeLimit } if errors.Is(err, ErrNoMethod) || errors.Is(err, ErrMethodNotString) || diff --git a/ratelimiter/method_parser_test.go b/ratelimiter/method_parser_test.go index 989156f0ab..53670e811f 100644 --- a/ratelimiter/method_parser_test.go +++ b/ratelimiter/method_parser_test.go @@ -268,11 +268,20 @@ func TestParse_BodyOneByteOverLimitRejected(t *testing.T) { } func TestParse_DefaultProbeLimitApplied(t *testing.T) { - require.Equal(t, int64(DefaultMaxProbeBytes), NewMethodParser(0).maxProbeBytes) - require.Equal(t, int64(DefaultMaxProbeBytes), NewMethodParser(-5).maxProbeBytes) + require.Equal(t, int64(0), NewMethodParser(0).maxProbeBytes) + require.Equal(t, int64(0), NewMethodParser(-5).maxProbeBytes) require.Equal(t, int64(256), NewMethodParser(256).maxProbeBytes) } +func TestParse_UnlimitedProbeAcceptsLargeBody(t *testing.T) { + big := strings.Repeat("a", DefaultMaxProbeBytes) + body := `{"jsonrpc":"2.0","id":1,"params":["0x` + big + `"],"method":"broadcast_tx_sync"}` + methods, batch, err := NewMethodParser(0).Parse(strings.NewReader(body)) + require.NoError(t, err) + require.False(t, batch) + require.Equal(t, []string{"broadcast_tx_sync"}, methods) +} + func TestParse_MaxInt64ProbeLimitClamped(t *testing.T) { // math.MaxInt64 is clamped so the maxProbeBytes+1 sentinel in Parse cannot // overflow to a negative LimitedReader budget. diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit.go index 7c2094b9de..b03b78f113 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit.go @@ -26,11 +26,12 @@ type RateLimitGate struct { } // NewRateLimitGate returns a gate for CometBFT RPC HTTP (plane "cometbft"). -// registry must be non-nil. maxBodyBytes should match max-body-bytes; non-positive -// values use DefaultConfig().MaxBodyBytes. +// registry must be non-nil. maxBodyBytes is max-body-bytes from config; non-positive +// means unlimited and the gate does not apply its own body-size rejection (the outer +// MaxBytesHandler enforces a positive limit when configured). func NewRateLimitGate(registry *ratelimiter.Registry, maxBodyBytes int64, enabled bool) *RateLimitGate { if maxBodyBytes <= 0 { - maxBodyBytes = DefaultConfig().MaxBodyBytes + maxBodyBytes = 0 } if maxBodyBytes == math.MaxInt64 { maxBodyBytes = math.MaxInt64 - 1 diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go index 26b8d1c3bc..878cc69142 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go @@ -154,6 +154,10 @@ func readRateLimitBoundedBody(body io.ReadCloser, maxBytes int64) ([]byte, error _ = body.Close() }() + if maxBytes <= 0 { + return io.ReadAll(body) + } + lr := &io.LimitedReader{R: body, N: maxBytes + 1} buf, err := io.ReadAll(lr) if err != nil { diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go index 397f5edf9b..b1dea7d410 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go @@ -315,6 +315,28 @@ func TestRateLimitMiddleware_POST_OversizeBodyReturns413(t *testing.T) { require.Contains(t, rec.Body.String(), "request body too large") } +func TestRateLimitMiddleware_POST_UnlimitedBodyWhenMaxBodyBytesZero(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 100, 10) + gate := NewRateLimitGate(reg, 0, true) + called := false + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Greater(t, int64(len(body)), DefaultConfig().MaxBodyBytes) + w.WriteHeader(http.StatusOK) + }) + h := NewRateLimitMiddleware(inner, gate) + + body := `{"jsonrpc":"2.0","id":1,"method":"broadcast_tx_sync","params":["` + strings.Repeat("a", int(DefaultConfig().MaxBodyBytes)+1) + `"]}` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.RemoteAddr = "203.0.113.1:1" + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.True(t, called) +} + func TestRateLimitMiddleware_POST_MalformedJSONReturns400(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 100, 10) gate := NewRateLimitGate(reg, 0, true) From b5beb3deec901f9679d14a5dff1434a50f80d527 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 14 Aug 2026 16:19:14 +0200 Subject: [PATCH 11/21] Dropped double charge branch --- sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go index 878cc69142..1857b06516 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go @@ -7,7 +7,6 @@ import ( "io" "net/http" - "github.com/sei-protocol/sei-chain/ratelimiter" rpctypes "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/jsonrpc/types" ) @@ -68,10 +67,6 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) allowed, rejectMethod, checkErr := m.gate.CheckPOST(r.Context(), ip, bytes.NewReader(body)) if checkErr != nil { - if ratelimiter.IsBodyTooLarge(checkErr) { - m.rejectAdmission(r.Context(), w, r, ip, body, http.StatusRequestEntityTooLarge, rpctypes.CodeInvalidRequest, "request body too large") - return - } if isCometBFTPostJSONRPCRequest(r) { writeJSONRPCErrorWithStatus(w, body, http.StatusBadRequest, rpctypes.CodeParseError, "decoding request: %v", checkErr) return From 4645d4235661dd04ccb8d87ef1356fa800210b41 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 14 Aug 2026 16:22:35 +0200 Subject: [PATCH 12/21] Fixed http status StatusRequestEntityTooLarge --- sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go | 2 +- sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go index 1857b06516..4050486638 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go @@ -96,7 +96,7 @@ func (m *rateLimitMiddleware) rejectAdmission(ctx context.Context, w http.Respon http.Error(w, "too many requests", http.StatusTooManyRequests) return } - if isCometBFTPostJSONRPCRequest(r) && status != http.StatusRequestEntityTooLarge { + if isCometBFTPostJSONRPCRequest(r) { writeJSONRPCErrorWithStatus(w, body, status, code, "%s", msg) return } diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go index b1dea7d410..f2e77f840f 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go @@ -312,7 +312,7 @@ func TestRateLimitMiddleware_POST_OversizeBodyReturns413(t *testing.T) { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("x", 100)))) require.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) - require.Contains(t, rec.Body.String(), "request body too large") + requireJSONRPCError(t, rec.Body.Bytes(), int(rpctypes.CodeInvalidRequest), "request body too large") } func TestRateLimitMiddleware_POST_UnlimitedBodyWhenMaxBodyBytesZero(t *testing.T) { From b4df254f8a2e5224613de6f0218d0be44dfa5c09 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 14 Aug 2026 16:28:44 +0200 Subject: [PATCH 13/21] Added rate limit bucket for method catalogue --- .../rpc/jsonrpc/server/rate_limit.go | 14 +++++++++ .../jsonrpc/server/rate_limit_middleware.go | 24 +++++++++----- .../server/rate_limit_middleware_test.go | 31 ++++++++++--------- .../rpc/jsonrpc/server/rate_limit_test.go | 14 +++++++++ 4 files changed, 61 insertions(+), 22 deletions(-) diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit.go index b03b78f113..f3e2dcb043 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit.go @@ -12,6 +12,9 @@ import ( const cometbftRateLimitPlane = "cometbft" +// cometbftMethodCatalog is the fixed bucket label for browser catalog probes to /. +const cometbftMethodCatalog = "catalog" + var errInvalidURIMethod = errors.New("invalid URI method") // RateLimitGate applies per-IP token-bucket rate limiting for CometBFT RPC HTTP @@ -77,6 +80,17 @@ func (g *RateLimitGate) CheckPOST(ctx context.Context, ip string, body io.Reader return true, "", nil } +// CheckCatalog applies per-IP rate limits for browser catalog probes to /. +func (g *RateLimitGate) CheckCatalog(ctx context.Context, ip string) (allowed bool, rejectMethod string) { + if !g.enabled { + return true, "" + } + if !g.registry.Allow(ctx, ip, g.plane, cometbftMethodCatalog) { + return false, cometbftMethodCatalog + } + return true, "" +} + // CheckURI applies per-IP rate limits for REST-style GET/HEAD RPC routes. func (g *RateLimitGate) CheckURI(ctx context.Context, ip, path string) (allowed bool, rejectMethod string, err error) { if !g.enabled { diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go index 4050486638..5c07c7ba77 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go @@ -20,9 +20,10 @@ type rateLimitMiddleware struct { // NewRateLimitMiddleware wraps inner with CometBFT RPC HTTP rate-limit admission. // When gate is nil or disabled, inner is returned unchanged. // -// POST / JSON-RPC bodies and GET/HEAD URI routes (including the WebSocket -// handshake GET /websocket, charged as method "websocket") are limited. -// WebSocket frames after upgrade are not covered by this middleware. +// POST / JSON-RPC bodies, GET/HEAD URI routes (including the WebSocket +// handshake GET /websocket, charged as method "websocket"), and browser catalog +// probes to / with no body are limited. WebSocket frames after upgrade are not +// covered by this middleware. OPTIONS is exempt for CORS preflight. func NewRateLimitMiddleware(inner http.Handler, gate *RateLimitGate) http.Handler { if gate == nil || !gate.enabled { return inner @@ -37,6 +38,18 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) } ip := m.gate.registry.IPFromHTTPRequest(r) + if isCometBFTMethodCatalogRequest(r) { + allowed, rejectMethod := m.gate.CheckCatalog(r.Context(), ip) + if !allowed { + if rejectMethod != "" { + logger.Debug("rate limit rejected catalog request", "ip", ip, "method", rejectMethod, "plane", m.gate.plane) + } + http.Error(w, "too many requests", http.StatusTooManyRequests) + return + } + m.inner.ServeHTTP(w, r) + return + } if isCometBFTURIRPCRequest(r) { allowed, rejectMethod, checkErr := m.gate.CheckURI(r.Context(), ip, r.URL.Path) if checkErr != nil { @@ -105,10 +118,7 @@ func (m *rateLimitMiddleware) rejectAdmission(ctx context.Context, w http.Respon // isCometBFTRateLimitExemptRequest reports requests that should bypass the gate. func isCometBFTRateLimitExemptRequest(r *http.Request) bool { - if r.Method == http.MethodOptions { - return true - } - return isCometBFTMethodCatalogRequest(r) + return r.Method == http.MethodOptions } // isCometBFTMethodCatalogRequest reports browser/catalog probes to / with no body. diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go index f2e77f840f..5feb4c1607 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go @@ -185,27 +185,28 @@ func TestRateLimitMiddleware_DisabledBypassesGate(t *testing.T) { } } -func TestRateLimitMiddleware_MethodCatalogPassthrough(t *testing.T) { - reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true) - called := false +func TestRateLimitMiddleware_MethodCatalogRateLimited(t *testing.T) { inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - called = true w.WriteHeader(http.StatusOK) }) - h := NewRateLimitMiddleware(inner, gate) for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodPost} { t.Run(method, func(t *testing.T) { - called = false - for i := 0; i < 3; i++ { - req := httptest.NewRequest(method, "/", nil) - req.RemoteAddr = "203.0.113.1:1" - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - require.Equal(t, http.StatusOK, rec.Code, "iteration %d", i) - } - require.True(t, called) + reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true) + h := NewRateLimitMiddleware(inner, gate) + + req1 := httptest.NewRequest(method, "/", nil) + req1.RemoteAddr = "203.0.113.1:1" + rec1 := httptest.NewRecorder() + h.ServeHTTP(rec1, req1) + require.Equal(t, http.StatusOK, rec1.Code) + + req2 := httptest.NewRequest(method, "/", nil) + req2.RemoteAddr = "203.0.113.1:1" + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + require.Equal(t, http.StatusTooManyRequests, rec2.Code) }) } } diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_test.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_test.go index e77351d728..06b136b21f 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_test.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_test.go @@ -7,6 +7,20 @@ import ( "github.com/stretchr/testify/require" ) +func TestRateLimitGate_CheckCatalog_RejectsAfterBurst(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true) + ip := "203.0.113.50" + + allowed, rejectMethod := gate.CheckCatalog(t.Context(), ip) + require.True(t, allowed) + require.Empty(t, rejectMethod) + + allowed, rejectMethod = gate.CheckCatalog(t.Context(), ip) + require.False(t, allowed) + require.Equal(t, cometbftMethodCatalog, rejectMethod) +} + func TestRateLimitGate_CheckURI_InvalidPathReturns400(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 100, 10) gate := NewRateLimitGate(reg, 0, true) From 27ed5a80b3e621e3aeddc74e96a741d84413161e Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 14 Aug 2026 16:42:58 +0200 Subject: [PATCH 14/21] Allign middleware classification with mux --- .../jsonrpc/server/rate_limit_middleware.go | 56 +++++----------- .../server/rate_limit_middleware_test.go | 65 +++++++++++++++++++ 2 files changed, 81 insertions(+), 40 deletions(-) diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go index 5c07c7ba77..5821b44c7d 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go @@ -20,10 +20,10 @@ type rateLimitMiddleware struct { // NewRateLimitMiddleware wraps inner with CometBFT RPC HTTP rate-limit admission. // When gate is nil or disabled, inner is returned unchanged. // -// POST / JSON-RPC bodies, GET/HEAD URI routes (including the WebSocket -// handshake GET /websocket, charged as method "websocket"), and browser catalog -// probes to / with no body are limited. WebSocket frames after upgrade are not -// covered by this middleware. OPTIONS is exempt for CORS preflight. +// JSON-RPC POST bodies to /, URI routes on any other path (including POST +// /status and form-encoded /broadcast_tx_commit), and browser catalog probes +// to / with no body are limited. WebSocket frames after upgrade are not covered +// by this middleware. OPTIONS is exempt for CORS preflight. func NewRateLimitMiddleware(inner http.Handler, gate *RateLimitGate) http.Handler { if gate == nil || !gate.enabled { return inner @@ -70,50 +70,34 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) body, err := readRateLimitBoundedBody(r.Body, m.gate.maxBodyBytes) if err != nil { if isRateLimitRequestBodyTooLarge(err) { - m.rejectAdmission(r.Context(), w, r, ip, body, http.StatusRequestEntityTooLarge, rpctypes.CodeInvalidRequest, "request body too large") + m.rejectAdmission(r.Context(), w, ip, body, http.StatusRequestEntityTooLarge, rpctypes.CodeInvalidRequest, "request body too large") return } - m.rejectAdmission(r.Context(), w, r, ip, body, http.StatusBadRequest, rpctypes.CodeInvalidRequest, "bad request") + m.rejectAdmission(r.Context(), w, ip, body, http.StatusBadRequest, rpctypes.CodeInvalidRequest, "bad request") return } r.Body = io.NopCloser(bytes.NewReader(body)) allowed, rejectMethod, checkErr := m.gate.CheckPOST(r.Context(), ip, bytes.NewReader(body)) if checkErr != nil { - if isCometBFTPostJSONRPCRequest(r) { - writeJSONRPCErrorWithStatus(w, body, http.StatusBadRequest, rpctypes.CodeParseError, "decoding request: %v", checkErr) - return - } - http.Error(w, "bad request", http.StatusBadRequest) + writeJSONRPCErrorWithStatus(w, body, http.StatusBadRequest, rpctypes.CodeParseError, "decoding request: %v", checkErr) return } if !allowed { - logger.Debug("rate limit rejected POST request", "ip", ip, "method", rejectMethod, "plane", m.gate.plane) - if isCometBFTPostJSONRPCRequest(r) { - writeJSONRPCErrorWithStatus(w, body, http.StatusTooManyRequests, rpctypes.CodeInternalError, "too many requests") - return - } - http.Error(w, "too many requests", http.StatusTooManyRequests) + logger.Debug("rate limit rejected JSON-RPC request", "ip", ip, "method", rejectMethod, "plane", m.gate.plane) + writeJSONRPCErrorWithStatus(w, body, http.StatusTooManyRequests, rpctypes.CodeInternalError, "too many requests") return } m.inner.ServeHTTP(w, r) } -func (m *rateLimitMiddleware) rejectAdmission(ctx context.Context, w http.ResponseWriter, r *http.Request, ip string, body []byte, status int, code rpctypes.ErrorCode, msg string) { +func (m *rateLimitMiddleware) rejectAdmission(ctx context.Context, w http.ResponseWriter, ip string, body []byte, status int, code rpctypes.ErrorCode, msg string) { if m.gate.chargeAdmissionRejection(ctx, ip) { - if isCometBFTPostJSONRPCRequest(r) { - writeJSONRPCErrorWithStatus(w, body, http.StatusTooManyRequests, rpctypes.CodeInternalError, "too many requests") - return - } - http.Error(w, "too many requests", http.StatusTooManyRequests) - return - } - if isCometBFTPostJSONRPCRequest(r) { - writeJSONRPCErrorWithStatus(w, body, status, code, "%s", msg) + writeJSONRPCErrorWithStatus(w, body, http.StatusTooManyRequests, rpctypes.CodeInternalError, "too many requests") return } - http.Error(w, msg, status) + writeJSONRPCErrorWithStatus(w, body, status, code, "%s", msg) } // isCometBFTRateLimitExemptRequest reports requests that should bypass the gate. @@ -135,19 +119,11 @@ func isCometBFTMethodCatalogRequest(r *http.Request) bool { } } -// isCometBFTPostJSONRPCRequest reports POST requests to the JSON-RPC root path. -func isCometBFTPostJSONRPCRequest(r *http.Request) bool { - return r.Method == http.MethodPost && (r.URL.Path == "/" || r.URL.Path == "") -} - -// isCometBFTURIRPCRequest reports REST-style CometBFT RPC routes (GET /status, etc.). +// isCometBFTURIRPCRequest reports CometBFT URI RPC routes (/status, /block, etc.). +// The mux registers these for any HTTP method; params may arrive in the query +// string or form-encoded body. func isCometBFTURIRPCRequest(r *http.Request) bool { - switch r.Method { - case http.MethodGet, http.MethodHead: - return r.URL.Path != "/" && r.URL.Path != "" - default: - return false - } + return r.URL.Path != "/" && r.URL.Path != "" } func readRateLimitBoundedBody(body io.ReadCloser, maxBytes int64) ([]byte, error) { diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go index 5feb4c1607..61ed2f82d4 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go @@ -144,6 +144,71 @@ func TestRateLimitMiddleware_GET_ExtractsMethodFromPath(t *testing.T) { require.Equal(t, http.StatusTooManyRequests, rec2.Code) } +func TestRateLimitMiddleware_POSTURI_AllowsUnderLimit(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 100, 10) + gate := NewRateLimitGate(reg, 0, true) + + t.Run("query params", func(t *testing.T) { + called := false + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/status", r.URL.Path) + require.Equal(t, "1", r.URL.Query().Get("height")) + w.WriteHeader(http.StatusOK) + }) + h := NewRateLimitMiddleware(inner, gate) + + req := httptest.NewRequest(http.MethodPost, "/status?height=1", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.True(t, called) + }) + + t.Run("form body", func(t *testing.T) { + called := false + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/broadcast_tx_commit", r.URL.Path) + require.NoError(t, r.ParseForm()) + require.Equal(t, "74657374", r.Form.Get("tx")) + w.WriteHeader(http.StatusOK) + }) + h := NewRateLimitMiddleware(inner, gate) + + req := httptest.NewRequest(http.MethodPost, "/broadcast_tx_commit", strings.NewReader("tx=74657374")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.True(t, called) + }) +} + +func TestRateLimitMiddleware_POSTURI_RejectsAfterBurst(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true) + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h := NewRateLimitMiddleware(inner, gate) + remote := "203.0.113.20:1" + + req1 := httptest.NewRequest(http.MethodPost, "/status?height=1", nil) + req1.RemoteAddr = remote + rec1 := httptest.NewRecorder() + h.ServeHTTP(rec1, req1) + require.Equal(t, http.StatusOK, rec1.Code) + + req2 := httptest.NewRequest(http.MethodPost, "/status?height=1", nil) + req2.RemoteAddr = remote + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + require.Equal(t, http.StatusTooManyRequests, rec2.Code) +} + func TestRateLimitMiddleware_GET_PerIPIsolation(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) gate := NewRateLimitGate(reg, 0, true) From 1a9d58664e3dbdeed19879b20fb754fc64a68b41 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 14 Aug 2026 16:49:59 +0200 Subject: [PATCH 15/21] Fixed deferred drain issue --- sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go index 5821b44c7d..2292e83874 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go @@ -130,10 +130,7 @@ func readRateLimitBoundedBody(body io.ReadCloser, maxBytes int64) ([]byte, error if body == nil { return nil, errors.New("missing request body") } - defer func() { - _, _ = io.Copy(io.Discard, body) - _ = body.Close() - }() + defer body.Close() if maxBytes <= 0 { return io.ReadAll(body) @@ -145,6 +142,8 @@ func readRateLimitBoundedBody(body io.ReadCloser, maxBytes int64) ([]byte, error return nil, err } if int64(len(buf)) > maxBytes { + // Leave the unread remainder for net/http to drop the connection rather + // than draining an unbounded oversize payload here. return nil, errRateLimitBodyTooLarge } return buf, nil From c7948b46c4b7313f44a509359e0bf6c77d912547 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 17 Aug 2026 19:56:02 +0200 Subject: [PATCH 16/21] Added log during startup for negative rate limit values --- sei-tendermint/internal/inspect/rpc/rpc.go | 9 +++++++++ sei-tendermint/internal/rpc/core/env.go | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/sei-tendermint/internal/inspect/rpc/rpc.go b/sei-tendermint/internal/inspect/rpc/rpc.go index f34f89be50..da08de02ce 100644 --- a/sei-tendermint/internal/inspect/rpc/rpc.go +++ b/sei-tendermint/internal/inspect/rpc/rpc.go @@ -83,6 +83,15 @@ func Handler(rpcConfig *config.RPCConfig, routes core.RoutesMap) (http.Handler, rpcConfig.MaxBodyBytes, true, ) + if rpcConfig.IPRateLimitRPS <= 0 || rpcConfig.IPRateLimitBurst <= 0 { + logger.Info( + "RPC rate-limit admission is enabled but the token bucket is disabled "+ + "(ip-rate-limit-rps and/or ip-rate-limit-burst <= 0); HTTP 429 throttling will not occur", + "module", "rpc-server", + "ip-rate-limit-rps", rpcConfig.IPRateLimitRPS, + "ip-rate-limit-burst", rpcConfig.IPRateLimitBurst, + ) + } } rootHandler := server.NewRateLimitMiddleware(mux, rateLimitGate) if rpcConfig.IsCorsEnabled() { diff --git a/sei-tendermint/internal/rpc/core/env.go b/sei-tendermint/internal/rpc/core/env.go index 77bdb76d4e..dfbf25514b 100644 --- a/sei-tendermint/internal/rpc/core/env.go +++ b/sei-tendermint/internal/rpc/core/env.go @@ -334,6 +334,15 @@ func (env *Environment) StartService(ctx context.Context, conf *config.Config) ( conf.RPC.MaxBodyBytes, true, ) + if conf.RPC.IPRateLimitRPS <= 0 || conf.RPC.IPRateLimitBurst <= 0 { + logger.Info( + "RPC rate-limit admission is enabled but the token bucket is disabled "+ + "(ip-rate-limit-rps and/or ip-rate-limit-burst <= 0); HTTP 429 throttling will not occur", + "module", "rpc-server", + "ip-rate-limit-rps", conf.RPC.IPRateLimitRPS, + "ip-rate-limit-burst", conf.RPC.IPRateLimitBurst, + ) + } } // We may expose the RPC over both TCP and a Unix-domain socket. From 6922f768123c1d62691f2768c115fed3c7dee38c Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 17 Aug 2026 20:06:32 +0200 Subject: [PATCH 17/21] Removed Options rate limiter exemption --- .../jsonrpc/server/rate_limit_middleware.go | 16 ++----------- .../server/rate_limit_middleware_test.go | 23 ++++++++++--------- 2 files changed, 14 insertions(+), 25 deletions(-) diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go index 2292e83874..92ca23be1b 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go @@ -20,10 +20,8 @@ type rateLimitMiddleware struct { // NewRateLimitMiddleware wraps inner with CometBFT RPC HTTP rate-limit admission. // When gate is nil or disabled, inner is returned unchanged. // -// JSON-RPC POST bodies to /, URI routes on any other path (including POST -// /status and form-encoded /broadcast_tx_commit), and browser catalog probes -// to / with no body are limited. WebSocket frames after upgrade are not covered -// by this middleware. OPTIONS is exempt for CORS preflight. +// All HTTP verbs are limited, including OPTIONS; genuine CORS preflights are +// already terminated by the CORS handler wrapping this middleware. func NewRateLimitMiddleware(inner http.Handler, gate *RateLimitGate) http.Handler { if gate == nil || !gate.enabled { return inner @@ -32,11 +30,6 @@ func NewRateLimitMiddleware(inner http.Handler, gate *RateLimitGate) http.Handle } func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if isCometBFTRateLimitExemptRequest(r) { - m.inner.ServeHTTP(w, r) - return - } - ip := m.gate.registry.IPFromHTTPRequest(r) if isCometBFTMethodCatalogRequest(r) { allowed, rejectMethod := m.gate.CheckCatalog(r.Context(), ip) @@ -100,11 +93,6 @@ func (m *rateLimitMiddleware) rejectAdmission(ctx context.Context, w http.Respon writeJSONRPCErrorWithStatus(w, body, status, code, "%s", msg) } -// isCometBFTRateLimitExemptRequest reports requests that should bypass the gate. -func isCometBFTRateLimitExemptRequest(r *http.Request) bool { - return r.Method == http.MethodOptions -} - // isCometBFTMethodCatalogRequest reports browser/catalog probes to / with no body. // CometBFT's JSON-RPC handler serves the method list page for these requests. func isCometBFTMethodCatalogRequest(r *http.Request) bool { diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go index 61ed2f82d4..bb303e777b 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go @@ -300,24 +300,25 @@ func TestRateLimitMiddleware_GETRootWithBodyRateLimited(t *testing.T) { require.Equal(t, http.StatusTooManyRequests, rec2.Code) } -func TestRateLimitMiddleware_OPTIONSExempt(t *testing.T) { +func TestRateLimitMiddleware_OPTIONSNotExempt(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) gate := NewRateLimitGate(reg, 0, true) - called := false inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - called = true w.WriteHeader(http.StatusOK) }) h := NewRateLimitMiddleware(inner, gate) - for i := 0; i < 3; i++ { - req := httptest.NewRequest(http.MethodOptions, "/status", nil) - req.RemoteAddr = "203.0.113.1:1" - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - require.Equal(t, http.StatusOK, rec.Code) - } - require.True(t, called) + req1 := httptest.NewRequest(http.MethodOptions, "/status", nil) + req1.RemoteAddr = "203.0.113.1:1" + rec1 := httptest.NewRecorder() + h.ServeHTTP(rec1, req1) + require.Equal(t, http.StatusOK, rec1.Code) + + req2 := httptest.NewRequest(http.MethodOptions, "/status", nil) + req2.RemoteAddr = "203.0.113.1:1" + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + require.Equal(t, http.StatusTooManyRequests, rec2.Code) } func TestRateLimitMiddleware_POST_RejectionEmitsMetric(t *testing.T) { From ad36f60e25a800c77e6313756fa22070a12256b4 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 24 Aug 2026 11:21:08 +0200 Subject: [PATCH 18/21] Fixed unused import --- sei-tendermint/config/config_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/sei-tendermint/config/config_test.go b/sei-tendermint/config/config_test.go index 72ef9a10c4..a1911d3406 100644 --- a/sei-tendermint/config/config_test.go +++ b/sei-tendermint/config/config_test.go @@ -7,7 +7,6 @@ import ( "testing" "time" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" rpctypes "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/jsonrpc/types" "github.com/sei-protocol/sei-chain/sei-tendermint/types" "github.com/stretchr/testify/assert" From b7fe9ba1707f22d3fde407aa96fb602fea12de52 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 24 Aug 2026 11:36:40 +0200 Subject: [PATCH 19/21] Hoist shared rate-limit gate and fix catalog detection on /. Read the root path body before classifying catalog vs JSON-RPC so chunked and non-GET verbs match makeJSONRPCHandler; consolidate CheckJSONRPC and admission charging in ratelimiter and use nil gate as the only off switch. Co-authored-by: Cursor --- evmrpc/rate_limit.go | 73 --------------- evmrpc/rate_limit_middleware.go | 16 ++-- evmrpc/rate_limit_middleware_test.go | 92 ++++++------------- evmrpc/rpcstack.go | 5 +- evmrpc/server.go | 15 +-- ratelimiter/gate.go | 60 ++++++++++++ ratelimiter/gate_test.go | 62 +++++++++++++ sei-tendermint/internal/inspect/rpc/rpc.go | 1 - sei-tendermint/internal/rpc/core/env.go | 1 - .../rpc/jsonrpc/server/rate_limit.go | 59 ++---------- .../jsonrpc/server/rate_limit_middleware.go | 88 +++++++++--------- .../server/rate_limit_middleware_test.go | 54 +++++++---- .../rpc/jsonrpc/server/rate_limit_test.go | 6 +- 13 files changed, 255 insertions(+), 277 deletions(-) delete mode 100644 evmrpc/rate_limit.go create mode 100644 ratelimiter/gate.go create mode 100644 ratelimiter/gate_test.go diff --git a/evmrpc/rate_limit.go b/evmrpc/rate_limit.go deleted file mode 100644 index 08f6a9f7fd..0000000000 --- a/evmrpc/rate_limit.go +++ /dev/null @@ -1,73 +0,0 @@ -package evmrpc - -import ( - "context" - "io" - "math" - - "github.com/sei-protocol/sei-chain/ratelimiter" -) - -// RateLimitGate applies per-IP token-bucket rate limiting after extracting JSON-RPC -// method names from the entire request body (bounded by max_request_body_bytes). -// When enabled, method extraction and fail-closed rejection (HTTP 400/413) run even -// if the registry is configured with RPS or burst zero (no HTTP 429 only). -type RateLimitGate struct { - registry *ratelimiter.Registry - parser *ratelimiter.MethodParser - maxBodyBytes int64 - enabled bool - plane string -} - -// NewRateLimitGate returns a gate for the given plane ("evm"). registry must be non-nil. -// maxBodyBytes is the same cap as max_request_body_bytes; non-positive values use -// defaultMaxRequestBodyBytes. -func NewRateLimitGate(registry *ratelimiter.Registry, maxBodyBytes int64, enabled bool, plane string) *RateLimitGate { - if maxBodyBytes <= 0 { - maxBodyBytes = defaultMaxRequestBodyBytes - } - if maxBodyBytes == math.MaxInt64 { - maxBodyBytes = math.MaxInt64 - 1 - } - return &RateLimitGate{ - registry: registry, - parser: ratelimiter.NewMethodParser(maxBodyBytes), - maxBodyBytes: maxBodyBytes, - enabled: enabled, - plane: plane, - } -} - -// chargeAdmissionRejection consumes one token for a fail-closed rejection that -// never reaches method parsing (oversize body, read error). Returns true when -// the bucket is exhausted and the caller should respond with HTTP 429. -func (g *RateLimitGate) chargeAdmissionRejection(ctx context.Context, ip string) bool { - if !g.enabled { - return false - } - return !g.registry.Allow(ctx, ip, g.plane, ratelimiter.MethodInvalid) -} - -// Check parses body for JSON-RPC method names and applies per-IP rate limits. -// Parse errors still charge the bucket under ratelimiter.MethodInvalid so -// malformed bodies can't bypass rate limiting; if that exhausts the bucket, -// Check reports a rate-limit rejection instead of returning the parse error. -func (g *RateLimitGate) Check(ctx context.Context, ip string, body io.Reader) (allowed bool, rejectMethod string, err error) { - if !g.enabled { - return true, "", nil - } - - methods, _, parseErr := g.parser.Parse(body) - if parseErr != nil { - if !g.registry.Allow(ctx, ip, g.plane, ratelimiter.MethodInvalid) { - return false, ratelimiter.MethodInvalid, nil - } - return false, "", parseErr - } - - if n := len(methods); n > 0 && !g.registry.AllowN(ctx, ip, g.plane, methods[0], n) { - return false, methods[0], nil - } - return true, "", nil -} diff --git a/evmrpc/rate_limit_middleware.go b/evmrpc/rate_limit_middleware.go index bcec3c0e29..583af827d5 100644 --- a/evmrpc/rate_limit_middleware.go +++ b/evmrpc/rate_limit_middleware.go @@ -14,11 +14,11 @@ var errBodyTooLarge = errors.New("request body too large") type rateLimitMiddleware struct { inner http.Handler - gate *RateLimitGate + gate *ratelimiter.Gate } -func newRateLimitMiddleware(inner http.Handler, gate *RateLimitGate) http.Handler { - if gate == nil || !gate.enabled { +func newRateLimitMiddleware(inner http.Handler, gate *ratelimiter.Gate) http.Handler { + if gate == nil { return inner } return &rateLimitMiddleware{inner: inner, gate: gate} @@ -30,8 +30,8 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) return } - ip := m.gate.registry.IPFromHTTPRequest(r) - body, err := readBoundedBody(r.Body, m.gate.maxBodyBytes) + ip := m.gate.Registry().IPFromHTTPRequest(r) + body, err := readBoundedBody(r.Body, m.gate.MaxBodyBytes()) if err != nil { if isRequestBodyTooLarge(err) { m.rejectAdmission(r.Context(), w, ip, rejectReasonOversize, http.StatusRequestEntityTooLarge, "request body too large") @@ -42,7 +42,7 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) } r.Body = io.NopCloser(bytes.NewReader(body)) - allowed, rejectMethod, checkErr := m.gate.Check(r.Context(), ip, bytes.NewReader(body)) + allowed, rejectMethod, checkErr := m.gate.CheckJSONRPC(r.Context(), ip, bytes.NewReader(body)) if checkErr != nil { if ratelimiter.IsBodyTooLarge(checkErr) { m.rejectAdmission(r.Context(), w, ip, rejectReasonOversize, http.StatusRequestEntityTooLarge, "request body too large") @@ -53,7 +53,7 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) return } if !allowed { - logger.Debug("rate limit rejected request", "ip", ip, "method", rejectMethod, "plane", m.gate.plane) + logger.Debug("rate limit rejected request", "ip", ip, "method", rejectMethod, "plane", m.gate.Plane()) recordRequestRejected(r.Context(), rejectReasonRateLimited) http.Error(w, "too many requests", http.StatusTooManyRequests) return @@ -63,7 +63,7 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) } func (m *rateLimitMiddleware) rejectAdmission(ctx context.Context, w http.ResponseWriter, ip, reason string, status int, msg string) { - if m.gate.chargeAdmissionRejection(ctx, ip) { + if m.gate.ChargeAdmissionRejection(ctx, ip) { recordRequestRejected(ctx, rejectReasonRateLimited) http.Error(w, "too many requests", http.StatusTooManyRequests) return diff --git a/evmrpc/rate_limit_middleware_test.go b/evmrpc/rate_limit_middleware_test.go index 1e6d121b57..7217cc397b 100644 --- a/evmrpc/rate_limit_middleware_test.go +++ b/evmrpc/rate_limit_middleware_test.go @@ -2,7 +2,6 @@ package evmrpc import ( "io" - "math" "net/http" "net/http/httptest" "strings" @@ -23,9 +22,17 @@ func mustRateLimitRegistry(t *testing.T, rps float64, burst int) *ratelimiter.Re return reg } +func newTestRateLimitGate(t *testing.T, reg *ratelimiter.Registry, maxBodyBytes int64) *ratelimiter.Gate { + t.Helper() + if maxBodyBytes <= 0 { + maxBodyBytes = defaultMaxRequestBodyBytes + } + return ratelimiter.NewGate(reg, "evm", maxBodyBytes) +} + func TestRateLimitMiddleware_AllowsUnderLimit(t *testing.T) { reg := mustRateLimitRegistry(t, 100, 10) - gate := NewRateLimitGate(reg, 0, true, "evm") + gate := newTestRateLimitGate(t, reg, 0) called := false inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -49,7 +56,7 @@ func TestRateLimitMiddleware_AllowsUnderLimit(t *testing.T) { func TestRateLimitMiddleware_RejectsAfterBurst(t *testing.T) { reg := mustRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true, "evm") + gate := newTestRateLimitGate(t, reg, 0) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -72,7 +79,7 @@ func TestRateLimitMiddleware_RejectsAfterBurst(t *testing.T) { func TestRateLimitMiddleware_PerIPIsolation(t *testing.T) { reg := mustRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true, "evm") + gate := newTestRateLimitGate(t, reg, 0) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) h := newRateLimitMiddleware(inner, gate) body := `{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[]}` @@ -98,7 +105,7 @@ func TestRateLimitMiddleware_PerIPIsolation(t *testing.T) { func TestRateLimitMiddleware_BatchCountsAllMethods(t *testing.T) { reg := mustRateLimitRegistry(t, 0.001, 2) - gate := NewRateLimitGate(reg, 0, true, "evm") + gate := newTestRateLimitGate(t, reg, 0) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) h := newRateLimitMiddleware(inner, gate) @@ -122,7 +129,7 @@ func TestRateLimitMiddleware_BatchCountsAllMethods(t *testing.T) { func TestRateLimitMiddleware_ProbeLimitRejected413(t *testing.T) { reg := mustRateLimitRegistry(t, 100, 10) - gate := NewRateLimitGate(reg, 64, true, "evm") + gate := newTestRateLimitGate(t, reg, 64) padding := strings.Repeat(" ", 50) body := `{"params":[` + padding + `],"method":"eth_call","id":1}` @@ -140,7 +147,7 @@ func TestRateLimitMiddleware_ProbeLimitRejected413(t *testing.T) { func TestRateLimitMiddleware_OversizeChargesPerIP(t *testing.T) { reg := mustRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 64, true, "evm") + gate := newTestRateLimitGate(t, reg, 64) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Fatal("inner should not be called") }) @@ -166,7 +173,7 @@ func TestRateLimitMiddleware_OversizeChargesPerIP(t *testing.T) { func TestComposedStack_ChunkedOversizeRateLimitedAfterBurst(t *testing.T) { const maxBody = 100 reg := mustRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, maxBody, true, "evm") + gate := newTestRateLimitGate(t, reg, maxBody) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Fatal("inner should not be called") @@ -193,7 +200,7 @@ func TestComposedStack_ChunkedOversizeRateLimitedAfterBurst(t *testing.T) { func TestRateLimitMiddleware_ParseErrorRejected(t *testing.T) { reg := mustRateLimitRegistry(t, 100, 10) - gate := NewRateLimitGate(reg, 0, true, "evm") + gate := newTestRateLimitGate(t, reg, 0) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Fatal("inner should not be called") }) @@ -206,10 +213,8 @@ func TestRateLimitMiddleware_ParseErrorRejected(t *testing.T) { } func TestRateLimitMiddleware_DisabledBypasses(t *testing.T) { - reg := mustRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, false, "evm") inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) - h := newRateLimitMiddleware(inner, gate) + h := newRateLimitMiddleware(inner, nil) body := `{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[]}` for range 3 { @@ -223,7 +228,7 @@ func TestRateLimitMiddleware_DisabledBypasses(t *testing.T) { func TestRateLimitMiddleware_HealthCheckPassthrough(t *testing.T) { reg := mustRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true, "evm") + gate := newTestRateLimitGate(t, reg, 0) called := false inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { called = true @@ -245,7 +250,7 @@ func TestRateLimitMiddleware_HealthCheckPassthrough(t *testing.T) { func TestRateLimitMiddleware_OptionsPassthrough(t *testing.T) { reg := mustRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true, "evm") + gate := newTestRateLimitGate(t, reg, 0) called := false inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { called = true @@ -262,7 +267,7 @@ func TestRateLimitMiddleware_OptionsPassthrough(t *testing.T) { func TestRateLimitMiddleware_GetWithBodyRateLimitedLikePost(t *testing.T) { reg := mustRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true, "evm") + gate := newTestRateLimitGate(t, reg, 0) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -290,7 +295,7 @@ func TestRateLimitMiddleware_GetWithBodyRateLimitedLikePost(t *testing.T) { func TestComposedStack_RateLimitDistinctFromSizeBudget(t *testing.T) { const maxBody = 4096 reg := mustRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true, "evm") + gate := newTestRateLimitGate(t, reg, 0) enabled := BuildSeiLegacyEnabledSet([]string{"sei_getCosmosTx"}) body := `{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[]}` @@ -319,24 +324,10 @@ func TestComposedStack_RateLimitDistinctFromSizeBudget(t *testing.T) { require.Contains(t, rec2.Body.String(), "too many requests") } -func TestNewRateLimitGate_MaxInt64BodyLimitClamped(t *testing.T) { - reg := mustRateLimitRegistry(t, 100, 10) - gate := NewRateLimitGate(reg, math.MaxInt64, true, "evm") - require.Equal(t, int64(math.MaxInt64-1), gate.maxBodyBytes) - - body := `{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[]}` - inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) - h := newRateLimitMiddleware(inner, gate) - - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))) - require.Equal(t, http.StatusOK, rec.Code) -} - func TestComposedStack_OversizeContentLengthBeforeProbeRead(t *testing.T) { const maxBody = 100 reg := mustRateLimitRegistry(t, 100, 10) - gate := NewRateLimitGate(reg, 0, true, "evm") + gate := newTestRateLimitGate(t, reg, 0) var bodyRead bool base := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -360,7 +351,7 @@ func TestComposedStack_OversizeContentLengthBeforeProbeRead(t *testing.T) { func TestComposedStack_ChunkedOversizeReturns413(t *testing.T) { const maxBody = 100 reg := mustRateLimitRegistry(t, 100, 10) - gate := NewRateLimitGate(reg, maxBody, true, "evm") + gate := newTestRateLimitGate(t, reg, maxBody) var innerRan bool base := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -383,7 +374,7 @@ func TestComposedStack_ChunkedOversizeReturns413(t *testing.T) { func TestRateLimitMiddleware_MaxBytesReaderOversizeReturns413(t *testing.T) { const maxBody = 100 reg := mustRateLimitRegistry(t, 100, 10) - gate := NewRateLimitGate(reg, maxBody, true, "evm") + gate := newTestRateLimitGate(t, reg, maxBody) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Fatal("inner should not be called") }) @@ -400,37 +391,6 @@ func TestRateLimitMiddleware_MaxBytesReaderOversizeReturns413(t *testing.T) { require.Contains(t, rec.Body.String(), "request body too large") } -func TestRateLimitGate_Check(t *testing.T) { - reg := mustRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true, "evm") - - allowed, _, err := gate.Check(t.Context(), "1.2.3.4", strings.NewReader( - `{"method":"eth_call","id":1}`, - )) - require.NoError(t, err) - require.True(t, allowed) - - allowed, rejectMethod, err := gate.Check(t.Context(), "1.2.3.4", strings.NewReader( - `{"method":"eth_getBalance","id":2}`, - )) - require.NoError(t, err) - require.False(t, allowed) - require.Equal(t, "eth_getBalance", rejectMethod) -} - -func TestRateLimitGate_CheckProbeLimitRejected(t *testing.T) { - reg := mustRateLimitRegistry(t, 100, 10) - gate := NewRateLimitGate(reg, 64, true, "evm") - - padding := strings.Repeat(" ", 50) - body := `{"params":[` + padding + `],"method":"eth_call","id":1}` - - allowed, rejectMethod, err := gate.Check(t.Context(), "1.2.3.4", strings.NewReader(body)) - require.ErrorIs(t, err, ratelimiter.ErrProbeLimit) - require.False(t, allowed) - require.Empty(t, rejectMethod) -} - type trackedBody struct { io.Reader closed bool @@ -460,7 +420,7 @@ func TestRateLimitMiddleware_ParseErrorRecordsRejectedMetric(t *testing.T) { )) reg := mustRateLimitRegistry(t, 100, 10) - gate := NewRateLimitGate(reg, 0, true, "evm") + gate := newTestRateLimitGate(t, reg, 0) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -490,7 +450,7 @@ func TestRateLimitMiddleware_ParseErrorRecordsRejectedMetric(t *testing.T) { func TestRateLimitMiddleware_RejectionDrainsAndClosesBody(t *testing.T) { reg := mustRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true, "evm") + gate := newTestRateLimitGate(t, reg, 0) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) diff --git a/evmrpc/rpcstack.go b/evmrpc/rpcstack.go index cebca87739..286afcc07f 100644 --- a/evmrpc/rpcstack.go +++ b/evmrpc/rpcstack.go @@ -35,6 +35,7 @@ import ( "github.com/ethereum/go-ethereum/rpc" "github.com/gorilla/websocket" "github.com/rs/cors" + "github.com/sei-protocol/sei-chain/ratelimiter" "golang.org/x/net/netutil" ) @@ -49,8 +50,8 @@ type HTTPConfig struct { SeiLegacyAllowlist map[string]struct{} prefix string // path prefix on which to mount http handler RPCEndpointConfig - // rateLimitGate applies per-IP JSON-RPC rate limiting when non-nil and enabled. - rateLimitGate *RateLimitGate + // rateLimitGate applies per-IP JSON-RPC rate limiting when non-nil. + rateLimitGate *ratelimiter.Gate } // WsConfig is the JSON-RPC/Websocket configuration diff --git a/evmrpc/server.go b/evmrpc/server.go index 90dc4f2c1f..36f85da013 100644 --- a/evmrpc/server.go +++ b/evmrpc/server.go @@ -207,12 +207,15 @@ func NewEVMHTTPServer( if err != nil { return nil, fmt.Errorf("evm rate limiter: %w", err) } - httpConfig.rateLimitGate = NewRateLimitGate( - rateLimitRegistry, - config.MaxRequestBodyBytes, - config.RateLimitingEnabled, - "evm", - ) + var rateLimitGate *ratelimiter.Gate + if config.RateLimitingEnabled { + maxBody := config.MaxRequestBodyBytes + if maxBody <= 0 { + maxBody = defaultMaxRequestBodyBytes + } + rateLimitGate = ratelimiter.NewGate(rateLimitRegistry, "evm", maxBody) + } + httpConfig.rateLimitGate = rateLimitGate if err := httpServer.EnableRPC(apis, httpConfig); err != nil { return nil, err } diff --git a/ratelimiter/gate.go b/ratelimiter/gate.go new file mode 100644 index 0000000000..dc632308d0 --- /dev/null +++ b/ratelimiter/gate.go @@ -0,0 +1,60 @@ +package ratelimiter + +import ( + "context" + "io" + "math" +) + +// Gate applies per-IP token-bucket rate limiting after extracting JSON-RPC +// method names from the request body (bounded by maxBodyBytes). +type Gate struct { + registry *Registry + parser *MethodParser + maxBodyBytes int64 + plane string +} + +// NewGate returns a Gate for the given plane. registry must be non-nil. +// maxBodyBytes is passed through to MethodParser; callers normalize non-positive +// values according to their plane's policy before calling NewGate. +func NewGate(registry *Registry, plane string, maxBodyBytes int64) *Gate { + if maxBodyBytes == math.MaxInt64 { + maxBodyBytes = math.MaxInt64 - 1 + } + return &Gate{ + registry: registry, + parser: NewMethodParser(maxBodyBytes), + maxBodyBytes: maxBodyBytes, + plane: plane, + } +} + +func (g *Gate) Registry() *Registry { return g.registry } +func (g *Gate) MaxBodyBytes() int64 { return g.maxBodyBytes } +func (g *Gate) Plane() string { return g.plane } + +// ChargeAdmissionRejection consumes one token for a fail-closed rejection that +// never reaches method parsing (oversize body, read error). Returns true when +// the bucket is exhausted and the caller should respond with HTTP 429. +func (g *Gate) ChargeAdmissionRejection(ctx context.Context, ip string) bool { + return !g.registry.Allow(ctx, ip, g.plane, MethodInvalid) +} + +// CheckJSONRPC parses body for JSON-RPC method names and applies per-IP rate limits. +// Parse errors still charge the bucket under MethodInvalid so malformed bodies +// can't bypass rate limiting. +func (g *Gate) CheckJSONRPC(ctx context.Context, ip string, body io.Reader) (allowed bool, rejectMethod string, err error) { + methods, _, parseErr := g.parser.Parse(body) + if parseErr != nil { + if !g.registry.Allow(ctx, ip, g.plane, MethodInvalid) { + return false, MethodInvalid, nil + } + return false, "", parseErr + } + + if n := len(methods); n > 0 && !g.registry.AllowN(ctx, ip, g.plane, methods[0], n) { + return false, methods[0], nil + } + return true, "", nil +} diff --git a/ratelimiter/gate_test.go b/ratelimiter/gate_test.go new file mode 100644 index 0000000000..860130743c --- /dev/null +++ b/ratelimiter/gate_test.go @@ -0,0 +1,62 @@ +package ratelimiter + +import ( + "math" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func mustGateRegistry(t *testing.T, rps float64, burst int) *Registry { + t.Helper() + reg, err := New(Config{RPS: rps, Burst: burst}) + require.NoError(t, err) + return reg +} + +func TestGate_CheckJSONRPC(t *testing.T) { + reg := mustGateRegistry(t, 0.001, 1) + gate := NewGate(reg, "test", 0) + + allowed, _, err := gate.CheckJSONRPC(t.Context(), "1.2.3.4", strings.NewReader( + `{"method":"eth_call","id":1}`, + )) + require.NoError(t, err) + require.True(t, allowed) + + allowed, rejectMethod, err := gate.CheckJSONRPC(t.Context(), "1.2.3.4", strings.NewReader( + `{"method":"eth_getBalance","id":2}`, + )) + require.NoError(t, err) + require.False(t, allowed) + require.Equal(t, "eth_getBalance", rejectMethod) +} + +func TestGate_CheckJSONRPC_ProbeLimitRejected(t *testing.T) { + reg := mustGateRegistry(t, 100, 10) + gate := NewGate(reg, "test", 64) + + padding := strings.Repeat(" ", 50) + body := `{"params":[` + padding + `],"method":"eth_call","id":1}` + + allowed, rejectMethod, err := gate.CheckJSONRPC(t.Context(), "1.2.3.4", strings.NewReader(body)) + require.ErrorIs(t, err, ErrProbeLimit) + require.False(t, allowed) + require.Empty(t, rejectMethod) +} + +func TestGate_ChargeAdmissionRejection(t *testing.T) { + reg := mustGateRegistry(t, 0.001, 1) + gate := NewGate(reg, "test", 0) + ip := "203.0.113.50" + + require.False(t, gate.ChargeAdmissionRejection(t.Context(), ip)) + require.True(t, gate.ChargeAdmissionRejection(t.Context(), ip)) +} + +func TestNewGate_MaxInt64BodyLimitClamped(t *testing.T) { + reg := mustGateRegistry(t, 100, 10) + gate := NewGate(reg, "test", math.MaxInt64) + require.Equal(t, int64(math.MaxInt64-1), gate.MaxBodyBytes()) +} diff --git a/sei-tendermint/internal/inspect/rpc/rpc.go b/sei-tendermint/internal/inspect/rpc/rpc.go index da08de02ce..35e9673441 100644 --- a/sei-tendermint/internal/inspect/rpc/rpc.go +++ b/sei-tendermint/internal/inspect/rpc/rpc.go @@ -81,7 +81,6 @@ func Handler(rpcConfig *config.RPCConfig, routes core.RoutesMap) (http.Handler, rateLimitGate = server.NewRateLimitGate( rateLimitRegistry, rpcConfig.MaxBodyBytes, - true, ) if rpcConfig.IPRateLimitRPS <= 0 || rpcConfig.IPRateLimitBurst <= 0 { logger.Info( diff --git a/sei-tendermint/internal/rpc/core/env.go b/sei-tendermint/internal/rpc/core/env.go index dfbf25514b..5a2cc310db 100644 --- a/sei-tendermint/internal/rpc/core/env.go +++ b/sei-tendermint/internal/rpc/core/env.go @@ -332,7 +332,6 @@ func (env *Environment) StartService(ctx context.Context, conf *config.Config) ( rateLimitGate = rpcserver.NewRateLimitGate( rateLimitRegistry, conf.RPC.MaxBodyBytes, - true, ) if conf.RPC.IPRateLimitRPS <= 0 || conf.RPC.IPRateLimitBurst <= 0 { logger.Info( diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit.go index f3e2dcb043..cf538ceace 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit.go @@ -4,7 +4,6 @@ import ( "context" "errors" "io" - "math" "strings" "github.com/sei-protocol/sei-chain/ratelimiter" @@ -21,71 +20,30 @@ var errInvalidURIMethod = errors.New("invalid URI method") // requests. POST JSON-RPC bodies are parsed with MethodParser before full decode; // GET URI routes are accounted by path-derived method names. type RateLimitGate struct { - registry *ratelimiter.Registry - parser *ratelimiter.MethodParser - maxBodyBytes int64 - enabled bool - plane string + *ratelimiter.Gate } // NewRateLimitGate returns a gate for CometBFT RPC HTTP (plane "cometbft"). // registry must be non-nil. maxBodyBytes is max-body-bytes from config; non-positive // means unlimited and the gate does not apply its own body-size rejection (the outer // MaxBytesHandler enforces a positive limit when configured). -func NewRateLimitGate(registry *ratelimiter.Registry, maxBodyBytes int64, enabled bool) *RateLimitGate { +func NewRateLimitGate(registry *ratelimiter.Registry, maxBodyBytes int64) *RateLimitGate { if maxBodyBytes <= 0 { maxBodyBytes = 0 } - if maxBodyBytes == math.MaxInt64 { - maxBodyBytes = math.MaxInt64 - 1 - } return &RateLimitGate{ - registry: registry, - parser: ratelimiter.NewMethodParser(maxBodyBytes), - maxBodyBytes: maxBodyBytes, - enabled: enabled, - plane: cometbftRateLimitPlane, - } -} - -// chargeAdmissionRejection consumes one token for a fail-closed rejection that -// never reaches method parsing (oversize body, read error). Returns true when -// the bucket is exhausted and the caller should respond with HTTP 429. -func (g *RateLimitGate) chargeAdmissionRejection(ctx context.Context, ip string) bool { - if !g.enabled { - return false + Gate: ratelimiter.NewGate(registry, cometbftRateLimitPlane, maxBodyBytes), } - return !g.registry.Allow(ctx, ip, g.plane, ratelimiter.MethodInvalid) } // CheckPOST parses body for JSON-RPC method names and applies per-IP rate limits. -// Parse errors still charge the bucket under ratelimiter.MethodInvalid so -// malformed bodies can't bypass rate limiting. func (g *RateLimitGate) CheckPOST(ctx context.Context, ip string, body io.Reader) (allowed bool, rejectMethod string, err error) { - if !g.enabled { - return true, "", nil - } - - methods, _, parseErr := g.parser.Parse(body) - if parseErr != nil { - if !g.registry.Allow(ctx, ip, g.plane, ratelimiter.MethodInvalid) { - return false, ratelimiter.MethodInvalid, nil - } - return false, "", parseErr - } - - if n := len(methods); n > 0 && !g.registry.AllowN(ctx, ip, g.plane, methods[0], n) { - return false, methods[0], nil - } - return true, "", nil + return g.CheckJSONRPC(ctx, ip, body) } // CheckCatalog applies per-IP rate limits for browser catalog probes to /. func (g *RateLimitGate) CheckCatalog(ctx context.Context, ip string) (allowed bool, rejectMethod string) { - if !g.enabled { - return true, "" - } - if !g.registry.Allow(ctx, ip, g.plane, cometbftMethodCatalog) { + if !g.Registry().Allow(ctx, ip, g.Plane(), cometbftMethodCatalog) { return false, cometbftMethodCatalog } return true, "" @@ -93,17 +51,14 @@ func (g *RateLimitGate) CheckCatalog(ctx context.Context, ip string) (allowed bo // CheckURI applies per-IP rate limits for REST-style GET/HEAD RPC routes. func (g *RateLimitGate) CheckURI(ctx context.Context, ip, path string) (allowed bool, rejectMethod string, err error) { - if !g.enabled { - return true, "", nil - } method := strings.TrimPrefix(path, "/") if method == "" { - if !g.registry.Allow(ctx, ip, g.plane, ratelimiter.MethodInvalid) { + if !g.Registry().Allow(ctx, ip, g.Plane(), ratelimiter.MethodInvalid) { return false, ratelimiter.MethodInvalid, nil } return false, "", errInvalidURIMethod } - if !g.registry.Allow(ctx, ip, g.plane, method) { + if !g.Registry().Allow(ctx, ip, g.Plane(), method) { return false, method, nil } return true, "", nil diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go index 92ca23be1b..2de39519fb 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go @@ -18,31 +18,60 @@ type rateLimitMiddleware struct { } // NewRateLimitMiddleware wraps inner with CometBFT RPC HTTP rate-limit admission. -// When gate is nil or disabled, inner is returned unchanged. +// When gate is nil, inner is returned unchanged. // // All HTTP verbs are limited, including OPTIONS; genuine CORS preflights are // already terminated by the CORS handler wrapping this middleware. func NewRateLimitMiddleware(inner http.Handler, gate *RateLimitGate) http.Handler { - if gate == nil || !gate.enabled { + if gate == nil { return inner } return &rateLimitMiddleware{inner: inner, gate: gate} } func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ip := m.gate.registry.IPFromHTTPRequest(r) - if isCometBFTMethodCatalogRequest(r) { - allowed, rejectMethod := m.gate.CheckCatalog(r.Context(), ip) - if !allowed { - if rejectMethod != "" { - logger.Debug("rate limit rejected catalog request", "ip", ip, "method", rejectMethod, "plane", m.gate.plane) + ip := m.gate.Registry().IPFromHTTPRequest(r) + + if isCometBFTRootPath(r) { + body, err := readRateLimitBoundedBody(r.Body, m.gate.MaxBodyBytes()) + if err != nil { + if isRateLimitRequestBodyTooLarge(err) { + m.rejectAdmission(r.Context(), w, ip, body, http.StatusRequestEntityTooLarge, rpctypes.CodeInvalidRequest, "request body too large") + return } - http.Error(w, "too many requests", http.StatusTooManyRequests) + m.rejectAdmission(r.Context(), w, ip, body, http.StatusBadRequest, rpctypes.CodeInvalidRequest, "bad request") + return + } + r.Body = io.NopCloser(bytes.NewReader(body)) + + if len(body) == 0 { + allowed, rejectMethod := m.gate.CheckCatalog(r.Context(), ip) + if !allowed { + if rejectMethod != "" { + logger.Debug("rate limit rejected catalog request", "ip", ip, "method", rejectMethod, "plane", m.gate.Plane()) + } + http.Error(w, "too many requests", http.StatusTooManyRequests) + return + } + m.inner.ServeHTTP(w, r) return } + + allowed, rejectMethod, checkErr := m.gate.CheckPOST(r.Context(), ip, bytes.NewReader(body)) + if checkErr != nil { + writeJSONRPCErrorWithStatus(w, body, http.StatusBadRequest, rpctypes.CodeParseError, "decoding request: %v", checkErr) + return + } + if !allowed { + logger.Debug("rate limit rejected JSON-RPC request", "ip", ip, "method", rejectMethod, "plane", m.gate.Plane()) + writeJSONRPCErrorWithStatus(w, body, http.StatusTooManyRequests, rpctypes.CodeInternalError, "too many requests") + return + } + m.inner.ServeHTTP(w, r) return } + if isCometBFTURIRPCRequest(r) { allowed, rejectMethod, checkErr := m.gate.CheckURI(r.Context(), ip, r.URL.Path) if checkErr != nil { @@ -51,7 +80,7 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) } if !allowed { if rejectMethod != "" { - logger.Debug("rate limit rejected URI request", "ip", ip, "method", rejectMethod, "plane", m.gate.plane) + logger.Debug("rate limit rejected URI request", "ip", ip, "method", rejectMethod, "plane", m.gate.Plane()) } http.Error(w, "too many requests", http.StatusTooManyRequests) return @@ -60,51 +89,20 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) return } - body, err := readRateLimitBoundedBody(r.Body, m.gate.maxBodyBytes) - if err != nil { - if isRateLimitRequestBodyTooLarge(err) { - m.rejectAdmission(r.Context(), w, ip, body, http.StatusRequestEntityTooLarge, rpctypes.CodeInvalidRequest, "request body too large") - return - } - m.rejectAdmission(r.Context(), w, ip, body, http.StatusBadRequest, rpctypes.CodeInvalidRequest, "bad request") - return - } - r.Body = io.NopCloser(bytes.NewReader(body)) - - allowed, rejectMethod, checkErr := m.gate.CheckPOST(r.Context(), ip, bytes.NewReader(body)) - if checkErr != nil { - writeJSONRPCErrorWithStatus(w, body, http.StatusBadRequest, rpctypes.CodeParseError, "decoding request: %v", checkErr) - return - } - if !allowed { - logger.Debug("rate limit rejected JSON-RPC request", "ip", ip, "method", rejectMethod, "plane", m.gate.plane) - writeJSONRPCErrorWithStatus(w, body, http.StatusTooManyRequests, rpctypes.CodeInternalError, "too many requests") - return - } - m.inner.ServeHTTP(w, r) } func (m *rateLimitMiddleware) rejectAdmission(ctx context.Context, w http.ResponseWriter, ip string, body []byte, status int, code rpctypes.ErrorCode, msg string) { - if m.gate.chargeAdmissionRejection(ctx, ip) { + if m.gate.ChargeAdmissionRejection(ctx, ip) { writeJSONRPCErrorWithStatus(w, body, http.StatusTooManyRequests, rpctypes.CodeInternalError, "too many requests") return } writeJSONRPCErrorWithStatus(w, body, status, code, "%s", msg) } -// isCometBFTMethodCatalogRequest reports browser/catalog probes to / with no body. -// CometBFT's JSON-RPC handler serves the method list page for these requests. -func isCometBFTMethodCatalogRequest(r *http.Request) bool { - if r.URL.Path != "/" && r.URL.Path != "" { - return false - } - switch r.Method { - case http.MethodGet, http.MethodHead, http.MethodPost: - return r.ContentLength == 0 - default: - return false - } +// isCometBFTRootPath reports requests to the JSON-RPC root (/). +func isCometBFTRootPath(r *http.Request) bool { + return r.URL.Path == "/" || r.URL.Path == "" } // isCometBFTURIRPCRequest reports CometBFT URI RPC routes (/status, /block, etc.). diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go index bb303e777b..6875a0575c 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go @@ -26,7 +26,7 @@ func mustCometBFTRateLimitRegistry(t *testing.T, rps float64, burst int) *rateli func TestRateLimitMiddleware_POST_AllowsUnderLimit(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 100, 10) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) called := false inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -50,7 +50,7 @@ func TestRateLimitMiddleware_POST_AllowsUnderLimit(t *testing.T) { func TestRateLimitMiddleware_POST_RejectsAfterBurst(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -73,7 +73,7 @@ func TestRateLimitMiddleware_POST_RejectsAfterBurst(t *testing.T) { func TestRateLimitMiddleware_POST_PerIPIsolation(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) h := NewRateLimitMiddleware(inner, gate) body := `{"jsonrpc":"2.0","id":1,"method":"status","params":[]}` @@ -99,7 +99,7 @@ func TestRateLimitMiddleware_POST_PerIPIsolation(t *testing.T) { func TestRateLimitMiddleware_POST_BatchCountsAllMethods(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 0.001, 2) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) h := NewRateLimitMiddleware(inner, gate) @@ -121,7 +121,7 @@ func TestRateLimitMiddleware_POST_BatchCountsAllMethods(t *testing.T) { func TestRateLimitMiddleware_GET_ExtractsMethodFromPath(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) called := false inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true @@ -146,7 +146,7 @@ func TestRateLimitMiddleware_GET_ExtractsMethodFromPath(t *testing.T) { func TestRateLimitMiddleware_POSTURI_AllowsUnderLimit(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 100, 10) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) t.Run("query params", func(t *testing.T) { called := false @@ -189,7 +189,7 @@ func TestRateLimitMiddleware_POSTURI_AllowsUnderLimit(t *testing.T) { func TestRateLimitMiddleware_POSTURI_RejectsAfterBurst(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -211,7 +211,7 @@ func TestRateLimitMiddleware_POSTURI_RejectsAfterBurst(t *testing.T) { func TestRateLimitMiddleware_GET_PerIPIsolation(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) h := NewRateLimitMiddleware(inner, gate) @@ -235,10 +235,8 @@ func TestRateLimitMiddleware_GET_PerIPIsolation(t *testing.T) { } func TestRateLimitMiddleware_DisabledBypassesGate(t *testing.T) { - reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, false) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) - h := NewRateLimitMiddleware(inner, gate) + h := NewRateLimitMiddleware(inner, nil) body := `{"jsonrpc":"2.0","id":1,"method":"status","params":[]}` for i := 0; i < 3; i++ { @@ -255,10 +253,10 @@ func TestRateLimitMiddleware_MethodCatalogRateLimited(t *testing.T) { w.WriteHeader(http.StatusOK) }) - for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodPost} { + for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodDelete} { t.Run(method, func(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) h := NewRateLimitMiddleware(inner, gate) req1 := httptest.NewRequest(method, "/", nil) @@ -278,7 +276,7 @@ func TestRateLimitMiddleware_MethodCatalogRateLimited(t *testing.T) { func TestRateLimitMiddleware_GETRootWithBodyRateLimited(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -302,7 +300,7 @@ func TestRateLimitMiddleware_GETRootWithBodyRateLimited(t *testing.T) { func TestRateLimitMiddleware_OPTIONSNotExempt(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -329,7 +327,7 @@ func TestRateLimitMiddleware_POST_RejectionEmitsMetric(t *testing.T) { t.Cleanup(func() { otel.SetMeterProvider(prev) }) reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) h := NewRateLimitMiddleware(inner, gate) @@ -370,7 +368,7 @@ func TestRateLimitMiddleware_POST_RejectionEmitsMetric(t *testing.T) { func TestRateLimitMiddleware_POST_OversizeBodyReturns413(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 100, 10) - gate := NewRateLimitGate(reg, 64, true) + gate := NewRateLimitGate(reg, 64) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Fatal("inner should not be called") }) @@ -384,7 +382,7 @@ func TestRateLimitMiddleware_POST_OversizeBodyReturns413(t *testing.T) { func TestRateLimitMiddleware_POST_UnlimitedBodyWhenMaxBodyBytesZero(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 100, 10) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) called := false inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true @@ -406,7 +404,7 @@ func TestRateLimitMiddleware_POST_UnlimitedBodyWhenMaxBodyBytesZero(t *testing.T func TestRateLimitMiddleware_POST_MalformedJSONReturns400(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 100, 10) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Fatal("inner should not be called") }) @@ -424,7 +422,7 @@ func TestRateLimitMiddleware_POST_MalformedJSONReturns400(t *testing.T) { func TestRateLimitMiddleware_GETRootServesMethodCatalog(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) h := NewRateLimitMiddleware(testMux(), gate) req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -436,6 +434,22 @@ func TestRateLimitMiddleware_GETRootServesMethodCatalog(t *testing.T) { require.Contains(t, rec.Body.String(), "Available RPC endpoints") } +func TestRateLimitMiddleware_MethodCatalogEmptyChunkedBody(t *testing.T) { + reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0) + h := NewRateLimitMiddleware(testMux(), gate) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("Transfer-Encoding", "chunked") + req.ContentLength = -1 + req.RemoteAddr = "203.0.113.1:1" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Header().Get("Content-Type"), "text/html") + require.Contains(t, rec.Body.String(), "Available RPC endpoints") +} + func requireJSONRPCError(t *testing.T, body []byte, wantCode int, wantSubstring string) { t.Helper() var resp rpctypes.RPCResponse diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_test.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_test.go index 06b136b21f..0fa635ccf1 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_test.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_test.go @@ -9,7 +9,7 @@ import ( func TestRateLimitGate_CheckCatalog_RejectsAfterBurst(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) ip := "203.0.113.50" allowed, rejectMethod := gate.CheckCatalog(t.Context(), ip) @@ -23,7 +23,7 @@ func TestRateLimitGate_CheckCatalog_RejectsAfterBurst(t *testing.T) { func TestRateLimitGate_CheckURI_InvalidPathReturns400(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 100, 10) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) allowed, rejectMethod, err := gate.CheckURI(t.Context(), "203.0.113.1", "/") require.False(t, allowed) @@ -33,7 +33,7 @@ func TestRateLimitGate_CheckURI_InvalidPathReturns400(t *testing.T) { func TestRateLimitGate_CheckURI_InvalidPathReturns429WhenBucketExhausted(t *testing.T) { reg := mustCometBFTRateLimitRegistry(t, 0.001, 1) - gate := NewRateLimitGate(reg, 0, true) + gate := NewRateLimitGate(reg, 0) ip := "203.0.113.50" allowed, rejectMethod, err := gate.CheckURI(t.Context(), ip, "/status") From 7149008081a0d31cd9421a574afd3d029fdab3ee Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 24 Aug 2026 12:22:00 +0200 Subject: [PATCH 20/21] Fixed error lint issue --- sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go index 2de39519fb..122922a4d4 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go @@ -116,7 +116,7 @@ func readRateLimitBoundedBody(body io.ReadCloser, maxBytes int64) ([]byte, error if body == nil { return nil, errors.New("missing request body") } - defer body.Close() + defer func() { _ = body.Close() }() if maxBytes <= 0 { return io.ReadAll(body) From d0408fe0b11200873aab651c4644879550b535a0 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 24 Aug 2026 14:02:20 +0200 Subject: [PATCH 21/21] Fixed metric method name for CometBFT --- ratelimiter/method_bucket.go | 65 ++++++++++++++++++- ratelimiter/method_bucket_test.go | 50 ++++++++++---- ratelimiter/registry.go | 4 +- ratelimiter/registry_test.go | 35 ++++++++++ .../rpc/jsonrpc/server/rate_limit.go | 4 +- .../server/rate_limit_middleware_test.go | 3 +- 6 files changed, 141 insertions(+), 20 deletions(-) diff --git a/ratelimiter/method_bucket.go b/ratelimiter/method_bucket.go index 12fab8470a..e350c9aca5 100644 --- a/ratelimiter/method_bucket.go +++ b/ratelimiter/method_bucket.go @@ -3,6 +3,9 @@ package ratelimiter import "strings" const ( + // PlaneCometBFT is the rate-limit plane label for Tendermint RPC HTTP. + PlaneCometBFT = "cometbft" + // rpcMethodBucketOther is the fallback label for unrecognized methods. rpcMethodBucketOther = "other" // MethodInvalid labels rate-limit charges for unparseable request bodies. @@ -29,13 +32,73 @@ var knownRPCNamespaces = map[string]struct{}{ "web3": {}, } +// knownCometBFTRPCMethods lists Tendermint RPC method names registered by the +// node. Rejection metrics on PlaneCometBFT record the method name directly +// rather than an EVM-style namespace prefix. +var knownCometBFTRPCMethods = map[string]struct{}{ + "abci_info": {}, + "abci_query": {}, + "block": {}, + "block_by_hash": {}, + "block_results": {}, + "block_search": {}, + "blockchain": {}, + "broadcast_evidence": {}, + "broadcast_tx": {}, + "broadcast_tx_async": {}, + "broadcast_tx_commit": {}, + "broadcast_tx_sync": {}, + "catalog": {}, + "check_tx": {}, + "commit": {}, + "consensus_params": {}, + "consensus_state": {}, + "dump_consensus_state": {}, + "events": {}, + "genesis": {}, + "genesis_chunked": {}, + "header": {}, + "header_by_hash": {}, + "health": {}, + "lag_status": {}, + "net_info": {}, + "num_unconfirmed_txs": {}, + "status": {}, + "subscribe": {}, + "tx": {}, + "tx_search": {}, + "unconfirmed_txs": {}, + "unsubscribe": {}, + "unsubscribe_all": {}, + "unsafe_flush_mempool": {}, + "validators": {}, + "websocket": {}, +} + // bucketRPCMethod maps a raw JSON-RPC method name to a low-cardinality label // suitable for OTel/Prometheus metrics. Attacker-controlled method strings // collapse to rpcMethodBucketOther. -func bucketRPCMethod(method string) string { +func bucketRPCMethod(plane, method string) string { if method == MethodInvalid { return MethodInvalid } + if plane == PlaneCometBFT { + return bucketCometBFTRPCMethod(method) + } + return bucketNamespacedRPCMethod(method) +} + +func bucketCometBFTRPCMethod(method string) string { + if method == "" || len(method) > maxRPCMethodLen { + return rpcMethodBucketOther + } + if _, ok := knownCometBFTRPCMethods[method]; ok { + return method + } + return rpcMethodBucketOther +} + +func bucketNamespacedRPCMethod(method string) string { if method == "" || len(method) > maxRPCMethodLen { return rpcMethodBucketOther } diff --git a/ratelimiter/method_bucket_test.go b/ratelimiter/method_bucket_test.go index 6bfb3699cf..100e3bb5ea 100644 --- a/ratelimiter/method_bucket_test.go +++ b/ratelimiter/method_bucket_test.go @@ -8,21 +8,21 @@ import ( ) func TestBucketRPCMethod_KnownNamespaces(t *testing.T) { - require.Equal(t, "eth", bucketRPCMethod("eth_call")) - require.Equal(t, "eth", bucketRPCMethod("eth_getBalance")) - require.Equal(t, "debug", bucketRPCMethod("debug_traceTransaction")) - require.Equal(t, "web3", bucketRPCMethod("web3_clientVersion")) + require.Equal(t, "eth", bucketRPCMethod("evm", "eth_call")) + require.Equal(t, "eth", bucketRPCMethod("evm", "eth_getBalance")) + require.Equal(t, "debug", bucketRPCMethod("evm", "debug_traceTransaction")) + require.Equal(t, "web3", bucketRPCMethod("evm", "web3_clientVersion")) } func TestBucketRPCMethod_UnknownOrMalformed(t *testing.T) { - require.Equal(t, rpcMethodBucketOther, bucketRPCMethod("")) - require.Equal(t, rpcMethodBucketOther, bucketRPCMethod("notnamespaced")) - require.Equal(t, rpcMethodBucketOther, bucketRPCMethod("eth")) - require.Equal(t, rpcMethodBucketOther, bucketRPCMethod("_eth_call")) - require.Equal(t, rpcMethodBucketOther, bucketRPCMethod("eth_")) - require.Equal(t, rpcMethodBucketOther, bucketRPCMethod("bogus_method")) - require.Equal(t, rpcMethodBucketOther, bucketRPCMethod("eth_BAD-chars")) - require.Equal(t, rpcMethodBucketOther, bucketRPCMethod(strings.Repeat("a", maxRPCMethodLen+1))) + require.Equal(t, rpcMethodBucketOther, bucketRPCMethod("evm", "")) + require.Equal(t, rpcMethodBucketOther, bucketRPCMethod("evm", "notnamespaced")) + require.Equal(t, rpcMethodBucketOther, bucketRPCMethod("evm", "eth")) + require.Equal(t, rpcMethodBucketOther, bucketRPCMethod("evm", "_eth_call")) + require.Equal(t, rpcMethodBucketOther, bucketRPCMethod("evm", "eth_")) + require.Equal(t, rpcMethodBucketOther, bucketRPCMethod("evm", "bogus_method")) + require.Equal(t, rpcMethodBucketOther, bucketRPCMethod("evm", "eth_BAD-chars")) + require.Equal(t, rpcMethodBucketOther, bucketRPCMethod("evm", strings.Repeat("a", maxRPCMethodLen+1))) } func TestBucketRPCMethod_ValidAndInvalidMethods(t *testing.T) { @@ -32,9 +32,33 @@ func TestBucketRPCMethod_ValidAndInvalidMethods(t *testing.T) { "eth_random-uuid-1", "eth_random-uuid-2", } { - seen[bucketRPCMethod(method)] = struct{}{} + seen[bucketRPCMethod("evm", method)] = struct{}{} } require.Len(t, seen, 2) require.Contains(t, seen, "eth") require.Contains(t, seen, rpcMethodBucketOther) } + +func TestBucketRPCMethod_CometBFTKnownMethods(t *testing.T) { + for _, method := range []string{ + "status", + "tx_search", + "block_search", + "broadcast_tx", + "catalog", + "websocket", + } { + require.Equal(t, method, bucketRPCMethod(PlaneCometBFT, method), method) + } +} + +func TestBucketRPCMethod_CometBFTUnknownMethods(t *testing.T) { + require.Equal(t, rpcMethodBucketOther, bucketRPCMethod(PlaneCometBFT, "")) + require.Equal(t, rpcMethodBucketOther, bucketRPCMethod(PlaneCometBFT, "bogus")) + require.Equal(t, rpcMethodBucketOther, bucketRPCMethod(PlaneCometBFT, strings.Repeat("a", maxRPCMethodLen+1))) +} + +func TestBucketRPCMethod_Invalid(t *testing.T) { + require.Equal(t, MethodInvalid, bucketRPCMethod("evm", MethodInvalid)) + require.Equal(t, MethodInvalid, bucketRPCMethod(PlaneCometBFT, MethodInvalid)) +} diff --git a/ratelimiter/registry.go b/ratelimiter/registry.go index 296e217831..7923f6e45c 100644 --- a/ratelimiter/registry.go +++ b/ratelimiter/registry.go @@ -101,7 +101,7 @@ func (r *Registry) Allow(ctx context.Context, ip, plane, method string) bool { 1, metric.WithAttributes( attribute.String("plane", plane), - attribute.String("method_namespace", bucketRPCMethod(method)), + attribute.String("method_namespace", bucketRPCMethod(plane, method)), ), ) return false @@ -121,7 +121,7 @@ func (r *Registry) AllowN(ctx context.Context, ip, plane, method string, n int) 1, metric.WithAttributes( attribute.String("plane", plane), - attribute.String("method_namespace", bucketRPCMethod(method)), + attribute.String("method_namespace", bucketRPCMethod(plane, method)), ), ) return false diff --git a/ratelimiter/registry_test.go b/ratelimiter/registry_test.go index 319a381942..59831685db 100644 --- a/ratelimiter/registry_test.go +++ b/ratelimiter/registry_test.go @@ -113,6 +113,41 @@ func TestAllow_RejectionRecordsMethodMetric(t *testing.T) { require.True(t, found, "expected rpc_rate_limit_rejected_total metric") } +func TestAllow_CometBFTRejectionRecordsMethodMetric(t *testing.T) { + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + prev := otel.GetMeterProvider() + otel.SetMeterProvider(provider) + t.Cleanup(func() { otel.SetMeterProvider(prev) }) + + registryMetrics.rejectedCounter = must(provider.Meter("ratelimiter").Int64Counter( + "rpc_rate_limit_rejected_total", + )) + + r := mustNew(t, cfg(0.001, 1)) + ip := "10.0.0.42" + require.True(t, r.Allow(t.Context(), ip, PlaneCometBFT, "status")) + require.False(t, r.Allow(t.Context(), ip, PlaneCometBFT, "tx_search")) + + var rm metricdata.ResourceMetrics + require.NoError(t, reader.Collect(t.Context(), &rm)) + found := false + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + if m.Name != "rpc_rate_limit_rejected_total" { + continue + } + sum := m.Data.(metricdata.Sum[int64]) + require.Equal(t, int64(1), sum.DataPoints[0].Value) + attrs := sum.DataPoints[0].Attributes.ToSlice() + require.Contains(t, attrs, attribute.String("plane", PlaneCometBFT)) + require.Contains(t, attrs, attribute.String("method_namespace", "tx_search")) + found = true + } + } + require.True(t, found, "expected rpc_rate_limit_rejected_total metric") +} + func TestAllow_IPv6_SamePrefixSharesBucket(t *testing.T) { r := mustNew(t, cfg(0.001, 1)) require.True(t, r.Allow(t.Context(), "2001:db8::1", "evm", "eth_call"), "first address in /64 passes") diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit.go index cf538ceace..7adc7b120c 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit.go @@ -9,8 +9,6 @@ import ( "github.com/sei-protocol/sei-chain/ratelimiter" ) -const cometbftRateLimitPlane = "cometbft" - // cometbftMethodCatalog is the fixed bucket label for browser catalog probes to /. const cometbftMethodCatalog = "catalog" @@ -32,7 +30,7 @@ func NewRateLimitGate(registry *ratelimiter.Registry, maxBodyBytes int64) *RateL maxBodyBytes = 0 } return &RateLimitGate{ - Gate: ratelimiter.NewGate(registry, cometbftRateLimitPlane, maxBodyBytes), + Gate: ratelimiter.NewGate(registry, ratelimiter.PlaneCometBFT, maxBodyBytes), } } diff --git a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go index 6875a0575c..39c8ea7a62 100644 --- a/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go +++ b/sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware_test.go @@ -355,10 +355,11 @@ func TestRateLimitMiddleware_POST_RejectionEmitsMetric(t *testing.T) { sum := m.Data.(metricdata.Sum[int64]) for _, dp := range sum.DataPoints { plane := attributeValue(dp.Attributes, "plane") - if plane != cometbftRateLimitPlane { + if plane != ratelimiter.PlaneCometBFT { continue } require.Equal(t, int64(1), dp.Value) + require.Equal(t, "status", attributeValue(dp.Attributes, "method_namespace")) found = true } }