From eca78305878dcc11be5b26d51d1b901cc99e4f9a Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 20 Jul 2026 14:59:44 +0200 Subject: [PATCH 01/14] Added wiring for rate limiter and method parser for evm plane --- evmrpc/config/config.go | 60 +++++++ evmrpc/config/config_test.go | 40 +++++ evmrpc/metrics.go | 8 +- evmrpc/rate_limit.go | 58 +++++++ evmrpc/rate_limit_middleware.go | 57 +++++++ evmrpc/rate_limit_middleware_test.go | 236 +++++++++++++++++++++++++++ evmrpc/request_limiter.go | 2 - evmrpc/rpcstack.go | 9 + evmrpc/server.go | 13 ++ ratelimiter/registry.go | 5 +- ratelimiter/registry_test.go | 69 ++++++-- 11 files changed, 536 insertions(+), 21 deletions(-) create mode 100644 evmrpc/rate_limit.go create mode 100644 evmrpc/rate_limit_middleware.go create mode 100644 evmrpc/rate_limit_middleware_test.go diff --git a/evmrpc/config/config.go b/evmrpc/config/config.go index db238922d0..795c176afc 100644 --- a/evmrpc/config/config.go +++ b/evmrpc/config/config.go @@ -6,6 +6,7 @@ import ( "time" "github.com/ethereum/go-ethereum/rpc" + "github.com/sei-protocol/sei-chain/ratelimiter" servertypes "github.com/sei-protocol/sei-chain/sei-cosmos/server/types" "github.com/spf13/cast" ) @@ -187,6 +188,19 @@ type Config struct { // IPRateLimitBurst is the maximum per-IP burst size. IPRateLimitBurst int `mapstructure:"ip_rate_limit_burst"` + // RateLimitingEnabled is the master switch for per-IP JSON-RPC rate limiting on + // the EVM HTTP plane. If disabled, requests bypass the rate limiter entirely. + 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"` + + // RateLimitProbeBytes bounds how far the rate limiter's MethodParser reads into + // a request body to extract the JSON-RPC method name. Independent of + // max_request_body_bytes. Zero uses the ratelimiter default (1 MiB). + RateLimitProbeBytes int64 `mapstructure:"rate_limit_probe_bytes"` + // BatchRequestLimit is the maximum number of requests allowed in a single // JSON-RPC batch (HTTP and WebSocket). Set to 0 to disable the limit. BatchRequestLimit int `mapstructure:"batch_request_limit"` @@ -264,6 +278,9 @@ var DefaultConfig = Config{ TraceBakeSnapshotWindow: 64, IPRateLimitRPS: 200, IPRateLimitBurst: 400, + RateLimitingEnabled: true, + TrustedProxyCIDRs: nil, + RateLimitProbeBytes: ratelimiter.DefaultMaxProbeBytes, BatchRequestLimit: 1000, BatchResponseMaxSize: 25 * 1000 * 1000, // 25MB MaxRequestBodyBytes: 5 * 1024 * 1024, // 5 MiB (matches go-ethereum rpc default body limit) @@ -316,6 +333,9 @@ const ( flagTraceBakeSnapshotWindow = "evm.trace_bake_snapshot_window" flagIPRateLimitRPS = "evm.ip_rate_limit_rps" flagIPRateLimitBurst = "evm.ip_rate_limit_burst" + flagRateLimitingEnabled = "evm.rate_limiting_enabled" + flagTrustedProxyCIDRs = "evm.trusted_proxy_cidrs" + flagRateLimitProbeBytes = "evm.rate_limit_probe_bytes" flagBatchRequestLimit = "evm.batch_request_limit" flagBatchResponseMaxSize = "evm.batch_response_max_size" flagMaxRequestBodyBytes = "evm.max_request_body_bytes" @@ -546,6 +566,21 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { return cfg, err } } + if v := opts.Get(flagRateLimitingEnabled); v != nil { + if cfg.RateLimitingEnabled, err = cast.ToBoolE(v); err != nil { + return cfg, err + } + } + if v := opts.Get(flagTrustedProxyCIDRs); v != nil { + if cfg.TrustedProxyCIDRs, err = cast.ToStringSliceE(v); err != nil { + return cfg, err + } + } + if v := opts.Get(flagRateLimitProbeBytes); v != nil { + if cfg.RateLimitProbeBytes, err = cast.ToInt64E(v); err != nil { + return cfg, err + } + } if v := opts.Get(flagBatchRequestLimit); v != nil { if cfg.BatchRequestLimit, err = cast.ToIntE(v); err != nil { return cfg, err @@ -577,6 +612,15 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { return cfg, nil } +// RateLimiterConfig builds the ratelimiter.Config used by EVM JSON-RPC admission. +func (c Config) RateLimiterConfig() ratelimiter.Config { + return ratelimiter.Config{ + RPS: c.IPRateLimitRPS, + Burst: c.IPRateLimitBurst, + TrustedProxyCIDRs: c.TrustedProxyCIDRs, + } +} + // ConfigTemplate defines the TOML configuration template for EVM RPC const ConfigTemplate = ` ############################################################################### @@ -787,6 +831,22 @@ ip_rate_limit_rps = {{ .EVM.IPRateLimitRPS }} # ip_rate_limit_burst is the maximum per-IP burst above the sustained rate. ip_rate_limit_burst = {{ .EVM.IPRateLimitBurst }} +# rate_limiting_enabled is the master switch for per-IP JSON-RPC rate limiting on +# the EVM HTTP plane (:8545). +rate_limiting_enabled = {{ .EVM.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 — set +# this to your ingress/LB CIDRs when the node sits behind a reverse proxy. +# Do NOT use the full RFC-1918 private ranges unless every address in them is +# your own controlled infrastructure. +trusted_proxy_cidrs = [{{- range $i, $c := .EVM.TrustedProxyCIDRs }}{{- if $i }}, {{ end }}"{{ $c }}"{{- end }}] + +# rate_limit_probe_bytes bounds how far the rate limiter reads into a request +# body to extract the JSON-RPC method name (default 1 MiB). Independent of +# max_request_body_bytes below. +rate_limit_probe_bytes = {{ .EVM.RateLimitProbeBytes }} + # batch_request_limit is the maximum number of requests allowed in a single # JSON-RPC batch (HTTP and WebSocket). Set to 0 to disable the limit. batch_request_limit = {{ .EVM.BatchRequestLimit }} diff --git a/evmrpc/config/config_test.go b/evmrpc/config/config_test.go index 48a17dd3cc..04dcd137e8 100644 --- a/evmrpc/config/config_test.go +++ b/evmrpc/config/config_test.go @@ -43,6 +43,9 @@ type opts struct { workerQueueSize interface{} ipRateLimitRPS interface{} ipRateLimitBurst interface{} + rateLimitingEnabled interface{} + trustedProxyCIDRs interface{} + rateLimitProbeBytes interface{} batchRequestLimit interface{} batchResponseMaxSize interface{} maxRequestBodyBytes interface{} @@ -168,6 +171,15 @@ func (o *opts) Get(k string) interface{} { if k == "evm.ip_rate_limit_burst" { return o.ipRateLimitBurst } + if k == "evm.rate_limiting_enabled" { + return o.rateLimitingEnabled + } + if k == "evm.trusted_proxy_cidrs" { + return o.trustedProxyCIDRs + } + if k == "evm.rate_limit_probe_bytes" { + return o.rateLimitProbeBytes + } if k == "evm.batch_request_limit" { return o.batchRequestLimit } @@ -232,6 +244,9 @@ func getDefaultOpts() opts { 1000, 200.0, 400, + nil, + nil, + nil, 1000, 25 * 1000 * 1000, int64(5 * 1024 * 1024), @@ -539,3 +554,28 @@ func TestReadConfigMaxSubscriptionsLogs(t *testing.T) { _, err = config.ReadConfig(&opts) require.Error(t, err) } + +func TestReadConfigRateLimiting(t *testing.T) { + cfg, err := config.ReadConfig(&opts{}) + require.NoError(t, err) + require.True(t, cfg.RateLimitingEnabled) + require.Nil(t, cfg.TrustedProxyCIDRs) + require.Equal(t, config.DefaultConfig.RateLimitProbeBytes, cfg.RateLimitProbeBytes) + require.Equal(t, config.DefaultConfig.IPRateLimitRPS, cfg.IPRateLimitRPS) + require.Equal(t, config.DefaultConfig.IPRateLimitBurst, cfg.IPRateLimitBurst) + + o := getDefaultOpts() + o.rateLimitingEnabled = false + o.trustedProxyCIDRs = []string{"10.0.0.0/8", "203.0.113.0/24"} + o.rateLimitProbeBytes = int64(2048) + cfg, err = config.ReadConfig(&o) + require.NoError(t, err) + require.False(t, cfg.RateLimitingEnabled) + require.Equal(t, []string{"10.0.0.0/8", "203.0.113.0/24"}, cfg.TrustedProxyCIDRs) + require.Equal(t, int64(2048), cfg.RateLimitProbeBytes) + + rlCfg := cfg.RateLimiterConfig() + require.Equal(t, cfg.IPRateLimitRPS, rlCfg.RPS) + require.Equal(t, cfg.IPRateLimitBurst, rlCfg.Burst) + require.Equal(t, cfg.TrustedProxyCIDRs, rlCfg.TrustedProxyCIDRs) +} diff --git a/evmrpc/metrics.go b/evmrpc/metrics.go index ead49300e2..6b61e5eb09 100644 --- a/evmrpc/metrics.go +++ b/evmrpc/metrics.go @@ -19,8 +19,9 @@ const ( jsonrpcCodeKey = "jsonrpc_code" rejectReasonKey = "reason" // reject reason values for requestRejectedCount. - rejectReasonOversize = "oversize" // body exceeded max_request_body_bytes - rejectReasonBusy = "busy" // max_concurrent_request_bytes budget exhausted + rejectReasonOversize = "oversize" // body exceeded max_request_body_bytes + rejectReasonBusy = "busy" // max_concurrent_request_bytes budget exhausted + rejectReasonRateLimited = "rate_limited" // per-IP token bucket exhausted // error_class values; empty string ("") means success. errorClassPanic = "panic" errorClassExecutionReverted = "execution_reverted" @@ -167,7 +168,8 @@ func recordHistoricalDebugTraceAttempt(ctx context.Context, endpoint, connection } // recordRequestRejected counts an HTTP JSON-RPC request dropped by pre-decode -// admission control. reason is one of rejectReasonOversize / rejectReasonBusy. +// admission control. reason is one of rejectReasonOversize / rejectReasonBusy / +// rejectReasonRateLimited. // No endpoint dimension is recorded: the rejection happens before the JSON-RPC // method is decoded, so it is not yet known. func recordRequestRejected(ctx context.Context, reason string) { diff --git a/evmrpc/rate_limit.go b/evmrpc/rate_limit.go new file mode 100644 index 0000000000..fe69672c8a --- /dev/null +++ b/evmrpc/rate_limit.go @@ -0,0 +1,58 @@ +package evmrpc + +import ( + "context" + "errors" + "io" + + "github.com/sei-protocol/sei-chain/ratelimiter" +) + +// RateLimitGate applies per-IP token-bucket rate limiting using a partial JSON +// read to extract JSON-RPC method names before full body decode. +type RateLimitGate struct { + registry *ratelimiter.Registry + parser *ratelimiter.MethodParser + maxProbeBytes int64 + enabled bool + plane string +} + +// NewRateLimitGate returns a gate for the given plane ("evm"). registry must be non-nil. +func NewRateLimitGate(registry *ratelimiter.Registry, probeBytes int64, enabled bool, plane string) *RateLimitGate { + if probeBytes <= 0 { + probeBytes = ratelimiter.DefaultMaxProbeBytes + } + return &RateLimitGate{ + registry: registry, + parser: ratelimiter.NewMethodParser(probeBytes), + maxProbeBytes: probeBytes, + enabled: enabled, + plane: plane, + } +} + +// Check parses body for JSON-RPC method names and applies per-IP rate limits. +// Returns passthrough=true when the probe budget was exhausted before finding a +// method (ErrProbeLimit) — callers should admit without a rate-limit decision. +// rejectMethod is the method that exhausted the bucket when allowed=false. +func (g *RateLimitGate) Check(ctx context.Context, ip string, body io.Reader) (allowed bool, rejectMethod string, passthrough bool, err error) { + if !g.enabled { + return true, "", false, nil + } + + methods, _, parseErr := g.parser.Parse(body) + switch { + case errors.Is(parseErr, ratelimiter.ErrProbeLimit): + return true, "", true, nil + case parseErr != nil: + return false, "", false, parseErr + } + + for _, method := range methods { + if !g.registry.Allow(ctx, ip, g.plane, method) { + return false, method, false, nil + } + } + return true, "", false, nil +} diff --git a/evmrpc/rate_limit_middleware.go b/evmrpc/rate_limit_middleware.go new file mode 100644 index 0000000000..a5d661a01a --- /dev/null +++ b/evmrpc/rate_limit_middleware.go @@ -0,0 +1,57 @@ +package evmrpc + +import ( + "bytes" + "io" + "net/http" +) + +type rateLimitMiddleware struct { + inner http.Handler + gate *RateLimitGate +} + +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 r.Method != http.MethodPost { + m.inner.ServeHTTP(w, r) + return + } + + ip := m.gate.registry.IPFromHTTPRequest(r) + prefix, err := readProbePrefix(r.Body, m.gate.maxProbeBytes) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + r.Body = restoreBody(prefix, r.Body) + + allowed, _, passthrough, checkErr := m.gate.Check(r.Context(), ip, bytes.NewReader(prefix)) + if checkErr != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if !allowed && !passthrough { + recordRequestRejected(r.Context(), rejectReasonRateLimited) + http.Error(w, "too many requests", http.StatusTooManyRequests) + return + } + + m.inner.ServeHTTP(w, r) +} + +// readProbePrefix reads up to maxProbeBytes plus one byte from body. +func readProbePrefix(body io.ReadCloser, maxProbeBytes int64) ([]byte, error) { + lr := &io.LimitedReader{R: body, N: maxProbeBytes + 1} + return io.ReadAll(lr) +} + +func restoreBody(prefix []byte, rest io.Reader) io.ReadCloser { + return io.NopCloser(io.MultiReader(bytes.NewReader(prefix), rest)) +} diff --git a/evmrpc/rate_limit_middleware_test.go b/evmrpc/rate_limit_middleware_test.go new file mode 100644 index 0000000000..dc566f3eaf --- /dev/null +++ b/evmrpc/rate_limit_middleware_test.go @@ -0,0 +1,236 @@ +package evmrpc + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/sei-protocol/sei-chain/ratelimiter" + "github.com/stretchr/testify/require" +) + +func mustRateLimitRegistry(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_AllowsUnderLimit(t *testing.T) { + reg := mustRateLimitRegistry(t, 100, 10) + gate := NewRateLimitGate(reg, 0, true, "evm") + + 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":"eth_call","params":[]}`, string(body)) + w.WriteHeader(http.StatusOK) + }) + + h := newRateLimitMiddleware(inner, gate) + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader( + `{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[]}`, + )) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.True(t, called) +} + +func TestRateLimitMiddleware_RejectsAfterBurst(t *testing.T) { + reg := mustRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true, "evm") + 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":[]}` + 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) + require.Contains(t, rec2.Body.String(), "too many requests") +} + +func TestRateLimitMiddleware_PerIPIsolation(t *testing.T) { + reg := mustRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true, "evm") + 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":[]}` + + 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_BatchCountsAllMethods(t *testing.T) { + reg := mustRateLimitRegistry(t, 0.001, 2) + gate := NewRateLimitGate(reg, 0, true, "evm") + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + h := newRateLimitMiddleware(inner, gate) + + // First batch consumes 2 tokens (burst=2). + batch := `[{"method":"eth_call","id":1},{"method":"eth_getBalance","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) + + // Second batch needs 1 token but bucket is empty. + req2 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader( + `{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[]}`, + )) + req2.RemoteAddr = "10.0.0.5:1" + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + require.Equal(t, http.StatusTooManyRequests, rec2.Code) +} + +func TestRateLimitMiddleware_ProbeLimitPassthrough(t *testing.T) { + reg := mustRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 64, true, "evm") + + padding := strings.Repeat(" ", 50) + body := `{"params":[` + padding + `],"method":"eth_call","id":1}` + + called := false + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + _, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + }) + h := newRateLimitMiddleware(inner, gate) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.RemoteAddr = "10.0.0.9:1" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.True(t, called) +} + +func TestRateLimitMiddleware_ParseErrorRejected(t *testing.T) { + reg := mustRateLimitRegistry(t, 100, 10) + gate := NewRateLimitGate(reg, 0, true, "evm") + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("inner should not be called") + }) + h := newRateLimitMiddleware(inner, gate) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"method":123}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code) +} + +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) + body := `{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[]}` + + for range 3 { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.RemoteAddr = "10.0.0.1:1" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + } +} + +func TestRateLimitMiddleware_NonPostPassthrough(t *testing.T) { + reg := mustRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true, "evm") + called := false + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + h := newRateLimitMiddleware(inner, gate) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.True(t, called) +} + +func TestComposedStack_RateLimitDistinctFromSizeBudget(t *testing.T) { + const maxBody = 4096 + reg := mustRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true, "evm") + enabled := BuildSeiLegacyEnabledSet([]string{"sei_getBlockByNumber"}) + + body := `{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[]}` + base := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + }) + stack := newRateLimitMiddleware( + newRequestSizeLimiter(wrapSeiLegacyHTTP(base, enabled, maxBody), maxBody, 0), + gate, + ) + + req1 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req1.RemoteAddr = "198.51.100.7:1" + rec1 := httptest.NewRecorder() + stack.ServeHTTP(rec1, req1) + require.Equal(t, http.StatusOK, rec1.Code) + + req2 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req2.RemoteAddr = "198.51.100.7:1" + rec2 := httptest.NewRecorder() + stack.ServeHTTP(rec2, req2) + require.Equal(t, http.StatusTooManyRequests, rec2.Code) + require.Contains(t, rec2.Body.String(), "too many requests") +} + +func TestRateLimitGate_Check(t *testing.T) { + reg := mustRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true, "evm") + + allowed, _, passthrough, err := gate.Check(t.Context(), "1.2.3.4", strings.NewReader( + `{"method":"eth_call","id":1}`, + )) + require.NoError(t, err) + require.True(t, allowed) + require.False(t, passthrough) + + allowed, rejectMethod, passthrough, 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) + require.False(t, passthrough) +} diff --git a/evmrpc/request_limiter.go b/evmrpc/request_limiter.go index b138df93ff..dfe3689710 100644 --- a/evmrpc/request_limiter.go +++ b/evmrpc/request_limiter.go @@ -20,8 +20,6 @@ const defaultMaxRequestBodyBytes int64 = 5 * 1024 * 1024 // over-budget requests get 429. The reservation is held for the full inner request // (not just decode) and trusts the declared Content-Length — conservative by design, // so slow or trickled requests can exhaust the budget even when little is buffered. -// Actual bytes stay hard-capped by MaxBytesReader; per-IP rate limiting will be -// wired in front of this handler. type requestSizeLimiter struct { inner http.Handler maxBody int64 // always > 0 after construction diff --git a/evmrpc/rpcstack.go b/evmrpc/rpcstack.go index 1fd9e6bead..d86528c442 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,6 +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 } // WsConfig is the JSON-RPC/Websocket configuration @@ -101,6 +104,8 @@ type HTTPServer struct { // Zero (the default) disables the limit. maxOpenConns int + rateLimitRegistry *ratelimiter.Registry + handlerNames map[string]string } @@ -353,6 +358,10 @@ func (h *HTTPServer) EnableRPC(apis []rpc.API, config HTTPConfig) error { config.maxRequestBodyBytes, config.maxConcurrentRequestBytes, ) + handler = newRateLimitMiddleware(handler, config.rateLimitGate) + if config.rateLimitGate != nil && h.rateLimitRegistry == nil { + h.rateLimitRegistry = config.rateLimitGate.registry + } h.httpHandler.Store(&rpcHandler{ Handler: handler, server: srv, diff --git a/evmrpc/server.go b/evmrpc/server.go index 0a03573df0..6f3be56f74 100644 --- a/evmrpc/server.go +++ b/evmrpc/server.go @@ -1,6 +1,7 @@ package evmrpc import ( + "fmt" "strings" "sync" @@ -9,6 +10,7 @@ import ( "github.com/sei-protocol/sei-chain/app/legacyabci" evmrpcconfig "github.com/sei-protocol/sei-chain/evmrpc/config" "github.com/sei-protocol/sei-chain/evmrpc/stats" + "github.com/sei-protocol/sei-chain/ratelimiter" "github.com/sei-protocol/sei-chain/sei-cosmos/baseapp" "github.com/sei-protocol/sei-chain/sei-cosmos/client" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" @@ -233,6 +235,17 @@ func NewEVMHTTPServer( httpConfig.batchResponseSizeLimit = config.BatchResponseMaxSize httpConfig.maxRequestBodyBytes = config.MaxRequestBodyBytes httpConfig.maxConcurrentRequestBytes = config.MaxConcurrentRequestBytes + rateLimitRegistry, err := ratelimiter.New(config.RateLimiterConfig()) + if err != nil { + return nil, fmt.Errorf("evm rate limiter: %w", err) + } + httpServer.rateLimitRegistry = rateLimitRegistry + httpConfig.rateLimitGate = NewRateLimitGate( + rateLimitRegistry, + config.RateLimitProbeBytes, + config.RateLimitingEnabled, + "evm", + ) if err := httpServer.EnableRPC(apis, httpConfig); err != nil { return nil, err } diff --git a/ratelimiter/registry.go b/ratelimiter/registry.go index cddf8d20fe..66767d817a 100644 --- a/ratelimiter/registry.go +++ b/ratelimiter/registry.go @@ -87,8 +87,8 @@ func New(cfg Config) (*Registry, error) { } // Allow reports whether the request from ip should be allowed for the given plane. -// Rejections increment rpc_rate_limit_rejected_total{plane}. -func (r *Registry) Allow(ctx context.Context, ip, plane string) bool { +// Rejections increment rpc_rate_limit_rejected_total{plane, method}. +func (r *Registry) Allow(ctx context.Context, ip, plane, method string) bool { if r.cfg.RPS <= 0 || r.cfg.Burst <= 0 { return true } @@ -100,6 +100,7 @@ func (r *Registry) Allow(ctx context.Context, ip, plane string) bool { 1, metric.WithAttributes( attribute.String("plane", plane), + attribute.String("method", method), ), ) return false diff --git a/ratelimiter/registry_test.go b/ratelimiter/registry_test.go index 4d51687782..10b470cef5 100644 --- a/ratelimiter/registry_test.go +++ b/ratelimiter/registry_test.go @@ -7,6 +7,10 @@ import ( "testing" "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" "google.golang.org/grpc/metadata" "google.golang.org/grpc/peer" ) @@ -36,21 +40,21 @@ func mustNew(t *testing.T, c Config) *Registry { func TestAllow_ZeroRPSAlwaysPasses(t *testing.T) { r := mustNew(t, zeroCfg) for range 1000 { - require.True(t, r.Allow(t.Context(), "1.2.3.4", "evm")) + require.True(t, r.Allow(t.Context(), "1.2.3.4", "evm", "eth_call")) } } func TestAllow_NegativeRPSAlwaysPasses(t *testing.T) { r := mustNew(t, negCfg) for range 1000 { - require.True(t, r.Allow(t.Context(), "1.2.3.4", "evm")) + require.True(t, r.Allow(t.Context(), "1.2.3.4", "evm", "eth_call")) } } func TestAllow_ZeroBurstAlwaysPasses(t *testing.T) { r := mustNew(t, zeroBurstCfg) for range 1000 { - require.True(t, r.Allow(t.Context(), "1.2.3.4", "evm")) + require.True(t, r.Allow(t.Context(), "1.2.3.4", "evm", "eth_call")) } } @@ -58,30 +62,67 @@ func TestAllow_BurstThenReject(t *testing.T) { // burst=3, RPS tiny so no token refill during test r := mustNew(t, cfg(0.001, 3)) ip := "10.0.0.1" - require.True(t, r.Allow(t.Context(), ip, "evm"), "first request in burst") - require.True(t, r.Allow(t.Context(), ip, "evm"), "second request in burst") - require.True(t, r.Allow(t.Context(), ip, "evm"), "third request in burst") - require.False(t, r.Allow(t.Context(), ip, "evm"), "must be rejected after burst exhausted") + require.True(t, r.Allow(t.Context(), ip, "evm", "eth_call"), "first request in burst") + require.True(t, r.Allow(t.Context(), ip, "evm", "eth_call"), "second request in burst") + require.True(t, r.Allow(t.Context(), ip, "evm", "eth_call"), "third request in burst") + require.False(t, r.Allow(t.Context(), ip, "evm", "eth_call"), "must be rejected after burst exhausted") } func TestAllow_PerIPIsolation(t *testing.T) { r := mustNew(t, cfg(0.001, 1)) - require.True(t, r.Allow(t.Context(), "1.1.1.1", "evm")) - require.False(t, r.Allow(t.Context(), "1.1.1.1", "evm"), "1.1.1.1 exhausted") + require.True(t, r.Allow(t.Context(), "1.1.1.1", "evm", "eth_call")) + require.False(t, r.Allow(t.Context(), "1.1.1.1", "evm", "eth_call"), "1.1.1.1 exhausted") // Different IP has its own independent bucket. - require.True(t, r.Allow(t.Context(), "2.2.2.2", "evm"), "2.2.2.2 should still pass") + require.True(t, r.Allow(t.Context(), "2.2.2.2", "evm", "eth_call"), "2.2.2.2 should still pass") +} + +func TestAllow_RejectionRecordsMethodMetric(t *testing.T) { + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + prev := otel.GetMeterProvider() + otel.SetMeterProvider(provider) + t.Cleanup(func() { otel.SetMeterProvider(prev) }) + + // Re-init metrics against the test provider. + 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, "evm", "eth_call")) + require.False(t, r.Allow(t.Context(), ip, "evm", "eth_getBalance")) + + var rm metricdata.ResourceMetrics + require.NoError(t, reader.Collect(t.Context(), &rm)) + require.NotEmpty(t, rm.ScopeMetrics) + 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", "evm")) + require.Contains(t, attrs, attribute.String("method", "eth_getBalance")) + 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"), "first address in /64 passes") - require.False(t, r.Allow(t.Context(), "2001:db8::2", "evm"), "different address in same /64 rejected") + require.True(t, r.Allow(t.Context(), "2001:db8::1", "evm", "eth_call"), "first address in /64 passes") + require.False(t, r.Allow(t.Context(), "2001:db8::2", "evm", "eth_call"), "different address in same /64 rejected") } func TestAllow_IPv6_DifferentPrefixOwnBucket(t *testing.T) { r := mustNew(t, cfg(0.001, 1)) - require.True(t, r.Allow(t.Context(), "2001:db8:0:1::1", "evm")) - require.True(t, r.Allow(t.Context(), "2001:db8:0:2::1", "evm"), "different /64 has its own bucket") + require.True(t, r.Allow(t.Context(), "2001:db8:0:1::1", "evm", "eth_call")) + require.True(t, r.Allow(t.Context(), "2001:db8:0:2::1", "evm", "eth_call"), "different /64 has its own bucket") } // --- IPFromHTTPRequest --- From 717a42530e4427af35d72cd9eeda58a050687f6c Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 20 Jul 2026 15:50:28 +0200 Subject: [PATCH 02/14] Added buckets for method name to reduce atrribute size for otel --- ratelimiter/method_bucket.go | 54 +++++++++++++++++++++++++++++++ ratelimiter/method_bucket_test.go | 41 +++++++++++++++++++++++ ratelimiter/registry.go | 2 +- ratelimiter/registry_test.go | 2 +- 4 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 ratelimiter/method_bucket.go create mode 100644 ratelimiter/method_bucket_test.go diff --git a/ratelimiter/method_bucket.go b/ratelimiter/method_bucket.go new file mode 100644 index 0000000000..6201decdfa --- /dev/null +++ b/ratelimiter/method_bucket.go @@ -0,0 +1,54 @@ +package ratelimiter + +import "strings" + +const ( + // rpcMethodBucketOther is the fallback label for unrecognized or malformed methods. + rpcMethodBucketOther = "other" + // maxRPCMethodLen rejects oversized method strings before metric recording. + maxRPCMethodLen = 128 +) + +// knownRPCNamespaces lists JSON-RPC namespaces the node may expose. Rejection +// metrics record the namespace (the prefix before the first '_') rather than the +// full method string, keeping OTel attribute cardinality bounded. +var knownRPCNamespaces = map[string]struct{}{ + "abci": {}, + "admin": {}, + "debug": {}, + "engine": {}, + "eth": {}, + "miner": {}, + "net": {}, + "personal": {}, + "sei": {}, + "sei2": {}, + "trace": {}, + "txpool": {}, + "web3": {}, +} + +// 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 { + if method == "" || len(method) > maxRPCMethodLen { + return rpcMethodBucketOther + } + underscore := strings.IndexByte(method, '_') + if underscore <= 0 || underscore >= len(method)-1 { + return rpcMethodBucketOther + } + ns := method[:underscore] + if _, ok := knownRPCNamespaces[ns]; !ok { + return rpcMethodBucketOther + } + suffix := method[underscore+1:] + for i := 0; i < len(suffix); i++ { + c := suffix[i] + if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '_' { + return rpcMethodBucketOther + } + } + return ns +} diff --git a/ratelimiter/method_bucket_test.go b/ratelimiter/method_bucket_test.go new file mode 100644 index 0000000000..c7a9b17966 --- /dev/null +++ b/ratelimiter/method_bucket_test.go @@ -0,0 +1,41 @@ +package ratelimiter + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +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, "sei2", bucketRPCMethod("sei2_getBlock")) +} + +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))) +} + +func TestBucketRPCMethod_ValidAndInvalidMethods(t *testing.T) { + seen := make(map[string]struct{}, 3) + for _, method := range []string{ + "eth_call", + "eth_random-uuid-1", + "eth_random-uuid-2", + } { + seen[bucketRPCMethod(method)] = struct{}{} + } + require.Len(t, seen, 2) + require.Contains(t, seen, "eth") + require.Contains(t, seen, rpcMethodBucketOther) +} diff --git a/ratelimiter/registry.go b/ratelimiter/registry.go index 66767d817a..ae40e1964f 100644 --- a/ratelimiter/registry.go +++ b/ratelimiter/registry.go @@ -100,7 +100,7 @@ func (r *Registry) Allow(ctx context.Context, ip, plane, method string) bool { 1, metric.WithAttributes( attribute.String("plane", plane), - attribute.String("method", method), + attribute.String("method", bucketRPCMethod(method)), ), ) return false diff --git a/ratelimiter/registry_test.go b/ratelimiter/registry_test.go index 10b470cef5..44a44841fc 100644 --- a/ratelimiter/registry_test.go +++ b/ratelimiter/registry_test.go @@ -106,7 +106,7 @@ func TestAllow_RejectionRecordsMethodMetric(t *testing.T) { require.Equal(t, int64(1), sum.DataPoints[0].Value) attrs := sum.DataPoints[0].Attributes.ToSlice() require.Contains(t, attrs, attribute.String("plane", "evm")) - require.Contains(t, attrs, attribute.String("method", "eth_getBalance")) + require.Contains(t, attrs, attribute.String("method", "eth")) found = true } } From f6c1bb5e6d5d9d5cdbc9ecd8df78b0f62653d3bd Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Mon, 20 Jul 2026 18:05:25 +0200 Subject: [PATCH 03/14] Addressing PR feedback --- evmrpc/metrics.go | 3 +- evmrpc/rate_limit.go | 24 ++-- evmrpc/rate_limit_middleware.go | 42 +++++- evmrpc/rate_limit_middleware_test.go | 204 ++++++++++++++++++++++++--- evmrpc/rpcstack.go | 9 +- 5 files changed, 249 insertions(+), 33 deletions(-) diff --git a/evmrpc/metrics.go b/evmrpc/metrics.go index 6b61e5eb09..11f15b4999 100644 --- a/evmrpc/metrics.go +++ b/evmrpc/metrics.go @@ -22,6 +22,7 @@ const ( rejectReasonOversize = "oversize" // body exceeded max_request_body_bytes rejectReasonBusy = "busy" // max_concurrent_request_bytes budget exhausted rejectReasonRateLimited = "rate_limited" // per-IP token bucket exhausted + rejectReasonUnparseable = "unparseable" // rate-limit probe could not parse JSON-RPC method(s) // error_class values; empty string ("") means success. errorClassPanic = "panic" errorClassExecutionReverted = "execution_reverted" @@ -169,7 +170,7 @@ func recordHistoricalDebugTraceAttempt(ctx context.Context, endpoint, connection // recordRequestRejected counts an HTTP JSON-RPC request dropped by pre-decode // admission control. reason is one of rejectReasonOversize / rejectReasonBusy / -// rejectReasonRateLimited. +// rejectReasonRateLimited / rejectReasonUnparseable. // No endpoint dimension is recorded: the rejection happens before the JSON-RPC // method is decoded, so it is not yet known. func recordRequestRejected(ctx context.Context, reason string) { diff --git a/evmrpc/rate_limit.go b/evmrpc/rate_limit.go index fe69672c8a..5a499bd42c 100644 --- a/evmrpc/rate_limit.go +++ b/evmrpc/rate_limit.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "math" "github.com/sei-protocol/sei-chain/ratelimiter" ) @@ -23,6 +24,9 @@ func NewRateLimitGate(registry *ratelimiter.Registry, probeBytes int64, enabled if probeBytes <= 0 { probeBytes = ratelimiter.DefaultMaxProbeBytes } + if probeBytes == math.MaxInt64 { + probeBytes = math.MaxInt64 - 1 + } return &RateLimitGate{ registry: registry, parser: ratelimiter.NewMethodParser(probeBytes), @@ -33,26 +37,30 @@ func NewRateLimitGate(registry *ratelimiter.Registry, probeBytes int64, enabled } // Check parses body for JSON-RPC method names and applies per-IP rate limits. -// Returns passthrough=true when the probe budget was exhausted before finding a -// method (ErrProbeLimit) — callers should admit without a rate-limit decision. // rejectMethod is the method that exhausted the bucket when allowed=false. -func (g *RateLimitGate) Check(ctx context.Context, ip string, body io.Reader) (allowed bool, rejectMethod string, passthrough bool, err error) { +func (g *RateLimitGate) Check(ctx context.Context, ip string, body io.Reader) (allowed bool, rejectMethod string, err error) { if !g.enabled { - return true, "", false, nil + return true, "", nil } methods, _, parseErr := g.parser.Parse(body) switch { case errors.Is(parseErr, ratelimiter.ErrProbeLimit): - return true, "", true, nil + // Body exceeded the probe budget without yielding a method (e.g. attacker + // padded params ahead of method). Charge a token anyway so large bodies + // can't dodge rate limiting by pushing method past the probe window. + if !g.registry.Allow(ctx, ip, g.plane, "") { + return false, "", nil + } + return true, "", nil case parseErr != nil: - return false, "", false, parseErr + return false, "", parseErr } for _, method := range methods { if !g.registry.Allow(ctx, ip, g.plane, method) { - return false, method, false, nil + return false, method, nil } } - return true, "", false, nil + return true, "", nil } diff --git a/evmrpc/rate_limit_middleware.go b/evmrpc/rate_limit_middleware.go index a5d661a01a..21712479d8 100644 --- a/evmrpc/rate_limit_middleware.go +++ b/evmrpc/rate_limit_middleware.go @@ -25,19 +25,25 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) } ip := m.gate.registry.IPFromHTTPRequest(r) - prefix, err := readProbePrefix(r.Body, m.gate.maxProbeBytes) + origBody := r.Body + prefix, err := readProbePrefix(origBody, m.gate.maxProbeBytes) if err != nil { + discardAndCloseBody(origBody) + recordRequestRejected(r.Context(), rejectReasonUnparseable) http.Error(w, "bad request", http.StatusBadRequest) return } - r.Body = restoreBody(prefix, r.Body) + r.Body = restoreBody(prefix, origBody) - allowed, _, passthrough, checkErr := m.gate.Check(r.Context(), ip, bytes.NewReader(prefix)) + allowed, _, checkErr := m.gate.Check(r.Context(), ip, bytes.NewReader(prefix)) if checkErr != nil { + discardAndCloseBody(r.Body) + recordRequestRejected(r.Context(), rejectReasonUnparseable) http.Error(w, "bad request", http.StatusBadRequest) return } - if !allowed && !passthrough { + if !allowed { + discardAndCloseBody(r.Body) recordRequestRejected(r.Context(), rejectReasonRateLimited) http.Error(w, "too many requests", http.StatusTooManyRequests) return @@ -52,6 +58,30 @@ func readProbePrefix(body io.ReadCloser, maxProbeBytes int64) ([]byte, error) { return io.ReadAll(lr) } -func restoreBody(prefix []byte, rest io.Reader) io.ReadCloser { - return io.NopCloser(io.MultiReader(bytes.NewReader(prefix), rest)) +// restoredBody replays a bounded prefix then the unread remainder of the +// original body. Close drains any leftover bytes and closes the original so +// rejected requests do not leave connections undrained. +type restoredBody struct { + io.Reader + orig io.ReadCloser +} + +func restoreBody(prefix []byte, orig io.ReadCloser) io.ReadCloser { + return &restoredBody{ + Reader: io.MultiReader(bytes.NewReader(prefix), orig), + orig: orig, + } +} + +func (b *restoredBody) Close() error { + _, _ = io.Copy(io.Discard, b.Reader) + return b.orig.Close() +} + +func discardAndCloseBody(body io.ReadCloser) { + if body == nil { + return + } + _, _ = io.Copy(io.Discard, body) + _ = body.Close() } diff --git a/evmrpc/rate_limit_middleware_test.go b/evmrpc/rate_limit_middleware_test.go index dc566f3eaf..d00c2606ec 100644 --- a/evmrpc/rate_limit_middleware_test.go +++ b/evmrpc/rate_limit_middleware_test.go @@ -2,6 +2,7 @@ package evmrpc import ( "io" + "math" "net/http" "net/http/httptest" "strings" @@ -9,6 +10,10 @@ import ( "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 mustRateLimitRegistry(t *testing.T, rps float64, burst int) *ratelimiter.Registry { @@ -115,27 +120,31 @@ func TestRateLimitMiddleware_BatchCountsAllMethods(t *testing.T) { require.Equal(t, http.StatusTooManyRequests, rec2.Code) } -func TestRateLimitMiddleware_ProbeLimitPassthrough(t *testing.T) { +func TestRateLimitMiddleware_ProbeLimitChargesToken(t *testing.T) { reg := mustRateLimitRegistry(t, 0.001, 1) gate := NewRateLimitGate(reg, 64, true, "evm") padding := strings.Repeat(" ", 50) body := `{"params":[` + padding + `],"method":"eth_call","id":1}` - called := false inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - called = true _, _ = io.ReadAll(r.Body) w.WriteHeader(http.StatusOK) }) h := newRateLimitMiddleware(inner, gate) - req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) - req.RemoteAddr = "10.0.0.9:1" - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - require.Equal(t, http.StatusOK, rec.Code) - require.True(t, called) + req1 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req1.RemoteAddr = "10.0.0.9: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 = "10.0.0.9:1" + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + require.Equal(t, http.StatusTooManyRequests, rec2.Code) + require.Contains(t, rec2.Body.String(), "too many requests") } func TestRateLimitMiddleware_ParseErrorRejected(t *testing.T) { @@ -196,9 +205,10 @@ func TestComposedStack_RateLimitDistinctFromSizeBudget(t *testing.T) { _, _ = io.ReadAll(r.Body) w.WriteHeader(http.StatusOK) }) - stack := newRateLimitMiddleware( - newRequestSizeLimiter(wrapSeiLegacyHTTP(base, enabled, maxBody), maxBody, 0), - gate, + stack := newRequestSizeLimiter( + newRateLimitMiddleware(wrapSeiLegacyHTTP(base, enabled, maxBody), gate), + maxBody, + 0, ) req1 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) @@ -215,22 +225,184 @@ func TestComposedStack_RateLimitDistinctFromSizeBudget(t *testing.T) { require.Contains(t, rec2.Body.String(), "too many requests") } +func TestNewRateLimitGate_MaxInt64ProbeLimitClamped(t *testing.T) { + reg := mustRateLimitRegistry(t, 100, 10) + gate := NewRateLimitGate(reg, math.MaxInt64, true, "evm") + require.Equal(t, int64(math.MaxInt64-1), gate.maxProbeBytes) + + 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") + + var bodyRead bool + base := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + bodyRead = len(b) > 0 + }) + stack := newRequestSizeLimiter(newRateLimitMiddleware(base, gate), maxBody, 0) + + tracked := &trackedBody{Reader: strings.NewReader(strings.Repeat("x", 200))} + req := httptest.NewRequest(http.MethodPost, "/", tracked) + req.ContentLength = 200 + + rec := httptest.NewRecorder() + stack.ServeHTTP(rec, req) + + require.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) + require.False(t, bodyRead) + require.Equal(t, int64(0), tracked.drained, "oversize body must be rejected before probe read") +} + func TestRateLimitGate_Check(t *testing.T) { reg := mustRateLimitRegistry(t, 0.001, 1) gate := NewRateLimitGate(reg, 0, true, "evm") - allowed, _, passthrough, err := gate.Check(t.Context(), "1.2.3.4", strings.NewReader( + 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) - require.False(t, passthrough) - allowed, rejectMethod, passthrough, err := gate.Check(t.Context(), "1.2.3.4", strings.NewReader( + 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) - require.False(t, passthrough) +} + +func TestRateLimitGate_CheckProbeLimitChargesToken(t *testing.T) { + reg := mustRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 64, true, "evm") + + padding := strings.Repeat(" ", 50) + body := `{"params":[` + padding + `],"method":"eth_call","id":1}` + + allowed, _, err := gate.Check(t.Context(), "1.2.3.4", strings.NewReader(body)) + require.NoError(t, err) + require.True(t, allowed) + + allowed, rejectMethod, err := gate.Check(t.Context(), "1.2.3.4", strings.NewReader(body)) + require.NoError(t, err) + require.False(t, allowed) + require.Empty(t, rejectMethod) +} + +type trackedBody struct { + io.Reader + closed bool + drained int64 +} + +func (b *trackedBody) Read(p []byte) (int, error) { + n, err := b.Reader.Read(p) + b.drained += int64(n) + return n, err +} + +func (b *trackedBody) Close() error { + b.closed = true + return nil +} + +func TestRateLimitMiddleware_ParseErrorRecordsRejectedMetric(t *testing.T) { + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + prev := otel.GetMeterProvider() + otel.SetMeterProvider(provider) + t.Cleanup(func() { otel.SetMeterProvider(prev) }) + + metrics.requestRejectedCount = must(provider.Meter("evmrpc").Int64Counter( + "evmrpc_requests_rejected_total", + )) + + reg := mustRateLimitRegistry(t, 100, 10) + gate := NewRateLimitGate(reg, 0, true, "evm") + 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(`{"method":123}`))) + require.Equal(t, http.StatusBadRequest, rec.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 != "evmrpc_requests_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(rejectReasonKey, rejectReasonUnparseable)) + found = true + } + } + require.True(t, found, "expected evmrpc_requests_rejected_total metric") +} + +func TestRateLimitMiddleware_RejectionDrainsAndClosesBody(t *testing.T) { + reg := mustRateLimitRegistry(t, 0.001, 1) + gate := NewRateLimitGate(reg, 0, true, "evm") + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h := newRateLimitMiddleware(inner, gate) + + fullBody := `{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[]}` + strings.Repeat(" ", 128) + + t.Run("rate limited", func(t *testing.T) { + req1 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(fullBody)) + req1.RemoteAddr = "203.0.113.50:1" + rec1 := httptest.NewRecorder() + h.ServeHTTP(rec1, req1) + require.Equal(t, http.StatusOK, rec1.Code) + + tracked := &trackedBody{Reader: strings.NewReader(fullBody)} + req2 := httptest.NewRequest(http.MethodPost, "/", tracked) + req2.RemoteAddr = "203.0.113.50:1" + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + require.Equal(t, http.StatusTooManyRequests, rec2.Code) + require.True(t, tracked.closed) + require.Equal(t, int64(len(fullBody)), tracked.drained) + }) + + t.Run("parse error", func(t *testing.T) { + badBody := `{"method":123}` + strings.Repeat("x", 64) + tracked := &trackedBody{Reader: strings.NewReader(badBody)} + req := httptest.NewRequest(http.MethodPost, "/", tracked) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.True(t, tracked.closed) + require.Equal(t, int64(len(badBody)), tracked.drained) + }) +} + +func TestRestoredBody_CloseDrainsRemainder(t *testing.T) { + orig := &trackedBody{Reader: strings.NewReader("tail-bytes")} + body := restoreBody([]byte("prefix-"), orig) + + buf, err := io.ReadAll(body) + require.NoError(t, err) + require.Equal(t, "prefix-tail-bytes", string(buf)) + + require.NoError(t, body.Close()) + require.True(t, orig.closed) + require.Equal(t, int64(len("tail-bytes")), orig.drained) } diff --git a/evmrpc/rpcstack.go b/evmrpc/rpcstack.go index d86528c442..13ad8a642e 100644 --- a/evmrpc/rpcstack.go +++ b/evmrpc/rpcstack.go @@ -353,12 +353,17 @@ func (h *HTTPServer) EnableRPC(apis []rpc.API, config HTTPConfig) error { // maxRequestBodyBytes feeds all three body-cap layers (requestSizeLimiter, the gate, and // srv.SetHTTPBodyLimit above) so they agree; change the cap via the config value, not one layer. - handler := newRequestSizeLimiter( + // requestSizeLimiter is outermost so declared oversize bodies are rejected from Content-Length + // before the rate limiter reads the probe prefix. + handler := newRateLimitMiddleware( wrapSeiLegacyHTTP(base, config.SeiLegacyAllowlist, config.maxRequestBodyBytes), + config.rateLimitGate, + ) + handler = newRequestSizeLimiter( + handler, config.maxRequestBodyBytes, config.maxConcurrentRequestBytes, ) - handler = newRateLimitMiddleware(handler, config.rateLimitGate) if config.rateLimitGate != nil && h.rateLimitRegistry == nil { h.rateLimitRegistry = config.rateLimitGate.registry } From 7d2027da9a898a229cc59f2883be8e1b576aaf7b Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Tue, 28 Jul 2026 15:44:43 +0200 Subject: [PATCH 04/14] Read and limit entire request for method parsing, use max body bytes instead of separate config value, updated tests --- evmrpc/config/config.go | 24 ++-------- evmrpc/config/config_test.go | 8 ---- evmrpc/metrics.go | 2 +- evmrpc/rate_limit.go | 49 ++++++++----------- evmrpc/rate_limit_middleware.go | 71 ++++++++++++++-------------- evmrpc/rate_limit_middleware_test.go | 64 +++++++++++-------------- evmrpc/rpcstack.go | 2 +- evmrpc/server.go | 2 +- ratelimiter/doc.go | 16 +++++++ ratelimiter/method_parser.go | 44 +++++++++++------ ratelimiter/method_parser_test.go | 23 +++++++-- 11 files changed, 155 insertions(+), 150 deletions(-) create mode 100644 ratelimiter/doc.go diff --git a/evmrpc/config/config.go b/evmrpc/config/config.go index 795c176afc..2996225319 100644 --- a/evmrpc/config/config.go +++ b/evmrpc/config/config.go @@ -196,11 +196,6 @@ type Config struct { // resolving the client IP for rate limiting. Empty means trust no proxy. TrustedProxyCIDRs []string `mapstructure:"trusted_proxy_cidrs"` - // RateLimitProbeBytes bounds how far the rate limiter's MethodParser reads into - // a request body to extract the JSON-RPC method name. Independent of - // max_request_body_bytes. Zero uses the ratelimiter default (1 MiB). - RateLimitProbeBytes int64 `mapstructure:"rate_limit_probe_bytes"` - // BatchRequestLimit is the maximum number of requests allowed in a single // JSON-RPC batch (HTTP and WebSocket). Set to 0 to disable the limit. BatchRequestLimit int `mapstructure:"batch_request_limit"` @@ -211,8 +206,8 @@ type Config struct { // MaxRequestBodyBytes is the maximum size, in bytes, of a single HTTP // JSON-RPC request body. Requests larger than this are rejected (HTTP 413) - // before the body is buffered or JSON-decoded. 0 uses the go-ethereum - // default (5 MiB). + // before the body is buffered or JSON-decoded, including at the rate limiter + // method-extraction layer. 0 uses the go-ethereum default (5 MiB). MaxRequestBodyBytes int64 `mapstructure:"max_request_body_bytes"` // MaxConcurrentRequestBytes bounds the total size, in bytes, of HTTP @@ -280,7 +275,6 @@ var DefaultConfig = Config{ IPRateLimitBurst: 400, RateLimitingEnabled: true, TrustedProxyCIDRs: nil, - RateLimitProbeBytes: ratelimiter.DefaultMaxProbeBytes, BatchRequestLimit: 1000, BatchResponseMaxSize: 25 * 1000 * 1000, // 25MB MaxRequestBodyBytes: 5 * 1024 * 1024, // 5 MiB (matches go-ethereum rpc default body limit) @@ -335,7 +329,6 @@ const ( flagIPRateLimitBurst = "evm.ip_rate_limit_burst" flagRateLimitingEnabled = "evm.rate_limiting_enabled" flagTrustedProxyCIDRs = "evm.trusted_proxy_cidrs" - flagRateLimitProbeBytes = "evm.rate_limit_probe_bytes" flagBatchRequestLimit = "evm.batch_request_limit" flagBatchResponseMaxSize = "evm.batch_response_max_size" flagMaxRequestBodyBytes = "evm.max_request_body_bytes" @@ -576,11 +569,6 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { return cfg, err } } - if v := opts.Get(flagRateLimitProbeBytes); v != nil { - if cfg.RateLimitProbeBytes, err = cast.ToInt64E(v); err != nil { - return cfg, err - } - } if v := opts.Get(flagBatchRequestLimit); v != nil { if cfg.BatchRequestLimit, err = cast.ToIntE(v); err != nil { return cfg, err @@ -842,11 +830,6 @@ rate_limiting_enabled = {{ .EVM.RateLimitingEnabled }} # your own controlled infrastructure. trusted_proxy_cidrs = [{{- range $i, $c := .EVM.TrustedProxyCIDRs }}{{- if $i }}, {{ end }}"{{ $c }}"{{- end }}] -# rate_limit_probe_bytes bounds how far the rate limiter reads into a request -# body to extract the JSON-RPC method name (default 1 MiB). Independent of -# max_request_body_bytes below. -rate_limit_probe_bytes = {{ .EVM.RateLimitProbeBytes }} - # batch_request_limit is the maximum number of requests allowed in a single # JSON-RPC batch (HTTP and WebSocket). Set to 0 to disable the limit. batch_request_limit = {{ .EVM.BatchRequestLimit }} @@ -857,7 +840,8 @@ batch_response_max_size = {{ .EVM.BatchResponseMaxSize }} # max_request_body_bytes is the maximum size, in bytes, of a single HTTP # JSON-RPC request body. Larger requests are rejected (HTTP 413) before the body -# is buffered or JSON-decoded. Set to 0 to use the default (5 MiB). +# is buffered or JSON-decoded, including at the rate limiter method-extraction +# layer. Set to 0 to use the default (5 MiB). max_request_body_bytes = {{ .EVM.MaxRequestBodyBytes }} # max_concurrent_request_bytes bounds the total size, in bytes, of HTTP JSON-RPC diff --git a/evmrpc/config/config_test.go b/evmrpc/config/config_test.go index 04dcd137e8..438943cff0 100644 --- a/evmrpc/config/config_test.go +++ b/evmrpc/config/config_test.go @@ -45,7 +45,6 @@ type opts struct { ipRateLimitBurst interface{} rateLimitingEnabled interface{} trustedProxyCIDRs interface{} - rateLimitProbeBytes interface{} batchRequestLimit interface{} batchResponseMaxSize interface{} maxRequestBodyBytes interface{} @@ -177,9 +176,6 @@ func (o *opts) Get(k string) interface{} { if k == "evm.trusted_proxy_cidrs" { return o.trustedProxyCIDRs } - if k == "evm.rate_limit_probe_bytes" { - return o.rateLimitProbeBytes - } if k == "evm.batch_request_limit" { return o.batchRequestLimit } @@ -246,7 +242,6 @@ func getDefaultOpts() opts { 400, nil, nil, - nil, 1000, 25 * 1000 * 1000, int64(5 * 1024 * 1024), @@ -560,19 +555,16 @@ func TestReadConfigRateLimiting(t *testing.T) { require.NoError(t, err) require.True(t, cfg.RateLimitingEnabled) require.Nil(t, cfg.TrustedProxyCIDRs) - require.Equal(t, config.DefaultConfig.RateLimitProbeBytes, cfg.RateLimitProbeBytes) require.Equal(t, config.DefaultConfig.IPRateLimitRPS, cfg.IPRateLimitRPS) require.Equal(t, config.DefaultConfig.IPRateLimitBurst, cfg.IPRateLimitBurst) o := getDefaultOpts() o.rateLimitingEnabled = false o.trustedProxyCIDRs = []string{"10.0.0.0/8", "203.0.113.0/24"} - o.rateLimitProbeBytes = int64(2048) cfg, err = config.ReadConfig(&o) require.NoError(t, err) require.False(t, cfg.RateLimitingEnabled) require.Equal(t, []string{"10.0.0.0/8", "203.0.113.0/24"}, cfg.TrustedProxyCIDRs) - require.Equal(t, int64(2048), cfg.RateLimitProbeBytes) rlCfg := cfg.RateLimiterConfig() require.Equal(t, cfg.IPRateLimitRPS, rlCfg.RPS) diff --git a/evmrpc/metrics.go b/evmrpc/metrics.go index 11f15b4999..6ee96c2171 100644 --- a/evmrpc/metrics.go +++ b/evmrpc/metrics.go @@ -22,7 +22,7 @@ const ( rejectReasonOversize = "oversize" // body exceeded max_request_body_bytes rejectReasonBusy = "busy" // max_concurrent_request_bytes budget exhausted rejectReasonRateLimited = "rate_limited" // per-IP token bucket exhausted - rejectReasonUnparseable = "unparseable" // rate-limit probe could not parse JSON-RPC method(s) + rejectReasonUnparseable = "unparseable" // rate-limit method parse failed (malformed JSON-RPC) // error_class values; empty string ("") means success. errorClassPanic = "panic" errorClassExecutionReverted = "execution_reverted" diff --git a/evmrpc/rate_limit.go b/evmrpc/rate_limit.go index 5a499bd42c..d03021b4fc 100644 --- a/evmrpc/rate_limit.go +++ b/evmrpc/rate_limit.go @@ -2,58 +2,51 @@ package evmrpc import ( "context" - "errors" "io" "math" "github.com/sei-protocol/sei-chain/ratelimiter" ) -// RateLimitGate applies per-IP token-bucket rate limiting using a partial JSON -// read to extract JSON-RPC method names before full body decode. +// 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). type RateLimitGate struct { - registry *ratelimiter.Registry - parser *ratelimiter.MethodParser - maxProbeBytes int64 - enabled bool - plane string + 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. -func NewRateLimitGate(registry *ratelimiter.Registry, probeBytes int64, enabled bool, plane string) *RateLimitGate { - if probeBytes <= 0 { - probeBytes = ratelimiter.DefaultMaxProbeBytes +// 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 probeBytes == math.MaxInt64 { - probeBytes = math.MaxInt64 - 1 + if maxBodyBytes == math.MaxInt64 { + maxBodyBytes = math.MaxInt64 - 1 } return &RateLimitGate{ - registry: registry, - parser: ratelimiter.NewMethodParser(probeBytes), - maxProbeBytes: probeBytes, - enabled: enabled, - plane: plane, + registry: registry, + parser: ratelimiter.NewMethodParser(maxBodyBytes), + maxBodyBytes: maxBodyBytes, + enabled: enabled, + plane: plane, } } // Check parses body for JSON-RPC method names and applies per-IP rate limits. // rejectMethod is the method that exhausted the bucket when allowed=false. +// Any Parse error, including ErrProbeLimit, is returned to the caller for rejection. 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) - switch { - case errors.Is(parseErr, ratelimiter.ErrProbeLimit): - // Body exceeded the probe budget without yielding a method (e.g. attacker - // padded params ahead of method). Charge a token anyway so large bodies - // can't dodge rate limiting by pushing method past the probe window. - if !g.registry.Allow(ctx, ip, g.plane, "") { - return false, "", nil - } - return true, "", nil - case parseErr != nil: + if parseErr != nil { return false, "", parseErr } diff --git a/evmrpc/rate_limit_middleware.go b/evmrpc/rate_limit_middleware.go index 21712479d8..c0797e0dc0 100644 --- a/evmrpc/rate_limit_middleware.go +++ b/evmrpc/rate_limit_middleware.go @@ -2,10 +2,15 @@ package evmrpc import ( "bytes" + "errors" "io" "net/http" + + "github.com/sei-protocol/sei-chain/ratelimiter" ) +var errBodyTooLarge = errors.New("request body too large") + type rateLimitMiddleware struct { inner http.Handler gate *RateLimitGate @@ -25,25 +30,31 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) } ip := m.gate.registry.IPFromHTTPRequest(r) - origBody := r.Body - prefix, err := readProbePrefix(origBody, m.gate.maxProbeBytes) + body, err := readBoundedBody(r.Body, m.gate.maxBodyBytes) if err != nil { - discardAndCloseBody(origBody) + if errors.Is(err, errBodyTooLarge) { + recordRequestRejected(r.Context(), rejectReasonOversize) + http.Error(w, "request body too large", http.StatusRequestEntityTooLarge) + return + } recordRequestRejected(r.Context(), rejectReasonUnparseable) http.Error(w, "bad request", http.StatusBadRequest) return } - r.Body = restoreBody(prefix, origBody) + r.Body = io.NopCloser(bytes.NewReader(body)) - allowed, _, checkErr := m.gate.Check(r.Context(), ip, bytes.NewReader(prefix)) + allowed, _, checkErr := m.gate.Check(r.Context(), ip, bytes.NewReader(body)) if checkErr != nil { - discardAndCloseBody(r.Body) + if ratelimiter.IsBodyTooLarge(checkErr) { + recordRequestRejected(r.Context(), rejectReasonOversize) + http.Error(w, "request body too large", http.StatusRequestEntityTooLarge) + return + } recordRequestRejected(r.Context(), rejectReasonUnparseable) http.Error(w, "bad request", http.StatusBadRequest) return } if !allowed { - discardAndCloseBody(r.Body) recordRequestRejected(r.Context(), rejectReasonRateLimited) http.Error(w, "too many requests", http.StatusTooManyRequests) return @@ -52,36 +63,24 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) m.inner.ServeHTTP(w, r) } -// readProbePrefix reads up to maxProbeBytes plus one byte from body. -func readProbePrefix(body io.ReadCloser, maxProbeBytes int64) ([]byte, error) { - lr := &io.LimitedReader{R: body, N: maxProbeBytes + 1} - return io.ReadAll(lr) -} - -// restoredBody replays a bounded prefix then the unread remainder of the -// original body. Close drains any leftover bytes and closes the original so -// rejected requests do not leave connections undrained. -type restoredBody struct { - io.Reader - orig io.ReadCloser -} - -func restoreBody(prefix []byte, orig io.ReadCloser) io.ReadCloser { - return &restoredBody{ - Reader: io.MultiReader(bytes.NewReader(prefix), orig), - orig: orig, +// readBoundedBody reads the entire request body, rejecting when it exceeds +// maxBytes. The original body is always drained and closed. +func readBoundedBody(body io.ReadCloser, maxBytes int64) ([]byte, error) { + if body == nil { + return nil, errors.New("missing request body") } -} - -func (b *restoredBody) Close() error { - _, _ = io.Copy(io.Discard, b.Reader) - return b.orig.Close() -} + defer func() { + _, _ = io.Copy(io.Discard, body) + _ = body.Close() + }() -func discardAndCloseBody(body io.ReadCloser) { - if body == nil { - return + 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, errBodyTooLarge } - _, _ = io.Copy(io.Discard, body) - _ = body.Close() + return buf, nil } diff --git a/evmrpc/rate_limit_middleware_test.go b/evmrpc/rate_limit_middleware_test.go index d00c2606ec..38a7deb9ce 100644 --- a/evmrpc/rate_limit_middleware_test.go +++ b/evmrpc/rate_limit_middleware_test.go @@ -120,31 +120,22 @@ func TestRateLimitMiddleware_BatchCountsAllMethods(t *testing.T) { require.Equal(t, http.StatusTooManyRequests, rec2.Code) } -func TestRateLimitMiddleware_ProbeLimitChargesToken(t *testing.T) { - reg := mustRateLimitRegistry(t, 0.001, 1) +func TestRateLimitMiddleware_ProbeLimitRejected413(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}` - inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = io.ReadAll(r.Body) - w.WriteHeader(http.StatusOK) + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("inner should not be called") }) h := newRateLimitMiddleware(inner, gate) - req1 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) - req1.RemoteAddr = "10.0.0.9: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 = "10.0.0.9:1" - rec2 := httptest.NewRecorder() - h.ServeHTTP(rec2, req2) - require.Equal(t, http.StatusTooManyRequests, rec2.Code) - require.Contains(t, rec2.Body.String(), "too many requests") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))) + require.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) + require.Contains(t, rec.Body.String(), "request body too large") } func TestRateLimitMiddleware_ParseErrorRejected(t *testing.T) { @@ -225,10 +216,10 @@ func TestComposedStack_RateLimitDistinctFromSizeBudget(t *testing.T) { require.Contains(t, rec2.Body.String(), "too many requests") } -func TestNewRateLimitGate_MaxInt64ProbeLimitClamped(t *testing.T) { +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.maxProbeBytes) + 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) }) @@ -281,19 +272,15 @@ func TestRateLimitGate_Check(t *testing.T) { require.Equal(t, "eth_getBalance", rejectMethod) } -func TestRateLimitGate_CheckProbeLimitChargesToken(t *testing.T) { - reg := mustRateLimitRegistry(t, 0.001, 1) +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, _, err := gate.Check(t.Context(), "1.2.3.4", strings.NewReader(body)) - require.NoError(t, err) - require.True(t, allowed) - allowed, rejectMethod, err := gate.Check(t.Context(), "1.2.3.4", strings.NewReader(body)) - require.NoError(t, err) + require.ErrorIs(t, err, ratelimiter.ErrProbeLimit) require.False(t, allowed) require.Empty(t, rejectMethod) } @@ -394,15 +381,20 @@ func TestRateLimitMiddleware_RejectionDrainsAndClosesBody(t *testing.T) { }) } -func TestRestoredBody_CloseDrainsRemainder(t *testing.T) { - orig := &trackedBody{Reader: strings.NewReader("tail-bytes")} - body := restoreBody([]byte("prefix-"), orig) - - buf, err := io.ReadAll(body) - require.NoError(t, err) - require.Equal(t, "prefix-tail-bytes", string(buf)) +func TestReadBoundedBody_RejectsOversize(t *testing.T) { + t.Run("exact limit ok", func(t *testing.T) { + body := `{"method":"eth_call","id":1}` + buf, err := readBoundedBody(io.NopCloser(strings.NewReader(body)), int64(len(body))) + require.NoError(t, err) + require.Equal(t, body, string(buf)) + }) - require.NoError(t, body.Close()) - require.True(t, orig.closed) - require.Equal(t, int64(len("tail-bytes")), orig.drained) + t.Run("one byte over limit", func(t *testing.T) { + body := `{"method":"eth_call","id":1}` + tracked := &trackedBody{Reader: strings.NewReader(body)} + _, err := readBoundedBody(tracked, int64(len(body)-1)) + require.ErrorIs(t, err, errBodyTooLarge) + require.True(t, tracked.closed) + require.Equal(t, int64(len(body)), tracked.drained) + }) } diff --git a/evmrpc/rpcstack.go b/evmrpc/rpcstack.go index 13ad8a642e..8a60be5575 100644 --- a/evmrpc/rpcstack.go +++ b/evmrpc/rpcstack.go @@ -354,7 +354,7 @@ func (h *HTTPServer) EnableRPC(apis []rpc.API, config HTTPConfig) error { // maxRequestBodyBytes feeds all three body-cap layers (requestSizeLimiter, the gate, and // srv.SetHTTPBodyLimit above) so they agree; change the cap via the config value, not one layer. // requestSizeLimiter is outermost so declared oversize bodies are rejected from Content-Length - // before the rate limiter reads the probe prefix. + // before the rate limiter reads the full body (bounded by max_request_body_bytes). handler := newRateLimitMiddleware( wrapSeiLegacyHTTP(base, config.SeiLegacyAllowlist, config.maxRequestBodyBytes), config.rateLimitGate, diff --git a/evmrpc/server.go b/evmrpc/server.go index 6f3be56f74..9e7a4751d5 100644 --- a/evmrpc/server.go +++ b/evmrpc/server.go @@ -242,7 +242,7 @@ func NewEVMHTTPServer( httpServer.rateLimitRegistry = rateLimitRegistry httpConfig.rateLimitGate = NewRateLimitGate( rateLimitRegistry, - config.RateLimitProbeBytes, + config.MaxRequestBodyBytes, config.RateLimitingEnabled, "evm", ) diff --git a/ratelimiter/doc.go b/ratelimiter/doc.go new file mode 100644 index 0000000000..3818531de5 --- /dev/null +++ b/ratelimiter/doc.go @@ -0,0 +1,16 @@ +// Package ratelimiter provides per-IP RPC rate limiting and JSON-RPC method +// extraction for endpoint protection. +// +// # 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 +// 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 +// where encoding/json keeps the last value). +// +// HTTP callers should map ErrProbeLimit to HTTP 413 ("request body too large") +// and all other Parse errors to HTTP 400. JSON-RPC planes should return an +// appropriate JSON-RPC error without dispatching the request. +package ratelimiter diff --git a/ratelimiter/method_parser.go b/ratelimiter/method_parser.go index addd961d4f..2e7da5f957 100644 --- a/ratelimiter/method_parser.go +++ b/ratelimiter/method_parser.go @@ -9,9 +9,8 @@ import ( "strings" ) -// DefaultMaxProbeBytes bounds how far MethodParser reads into a request body. Each object -// is read to its end (to detect duplicate "method" keys). A body larger than -// this limit yields ErrProbeLimit. +// DefaultMaxProbeBytes is the default MaxProbeBytes for MethodParser. The entire +// request body must fit within this limit; larger bodies yield ErrProbeLimit. const DefaultMaxProbeBytes = 1 << 20 // 1 MiB var ( @@ -29,24 +28,31 @@ var ( // ErrTrailingData is returned when non-whitespace data follows the top-level // JSON-RPC value; the downstream encoding/json decode would reject such a body. ErrTrailingData = errors.New("ratelimiter: JSON-RPC request has trailing data") - // ErrProbeLimit is returned when the method field is not found within MaxProbeBytes. - ErrProbeLimit = errors.New("ratelimiter: JSON-RPC method not found within probe limit") + // ErrProbeLimit is returned when the request body exceeds MaxProbeBytes. + ErrProbeLimit = errors.New("ratelimiter: JSON-RPC request body exceeds probe limit") ) -// MethodParser extracts JSON-RPC "method" name(s) from a request body via a -// streaming partial read. +// IsBodyTooLarge reports whether err indicates the request body exceeded +// MaxProbeBytes. HTTP callers should reject with status 413. +func IsBodyTooLarge(err error) bool { + return errors.Is(err, ErrProbeLimit) +} + +// MethodParser extracts JSON-RPC "method" name(s) from a request body. The +// entire body must fit within MaxProbeBytes so duplicate "method" keys cannot +// hide beyond a partial read. type MethodParser struct { maxProbeBytes int64 } -// NewMethodParser returns a MethodParser that reads at most maxProbeBytes from -// any single request body. +// NewMethodParser returns a MethodParser that accepts request bodies of at most +// maxProbeBytes bytes. Non-positive values use DefaultMaxProbeBytes. func NewMethodParser(maxProbeBytes int64) *MethodParser { if maxProbeBytes <= 0 { maxProbeBytes = DefaultMaxProbeBytes } if maxProbeBytes == math.MaxInt64 { - //preventing overflow error + // Prevent overflow when constructing the LimitedReader budget (+1). maxProbeBytes = math.MaxInt64 - 1 } return &MethodParser{maxProbeBytes: maxProbeBytes} @@ -56,10 +62,8 @@ func NewMethodParser(maxProbeBytes int64) *MethodParser { // carries. A single request object yields a one-element slice with batch=false; // a top-level array yields one method per element in order with batch=true. // -// Fail-closed contract: callers must reject on every returned error except -// ErrProbeLimit, and must fall back to a full decode (never reject) on -// ErrProbeLimit. Mapping any error to "admit with a default method" defeats -// the point of this probe. +// 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) { // N is maxProbeBytes+1 so that lr.N reaching 0 unambiguously means the body // exceeded the budget. @@ -85,6 +89,9 @@ func (p *MethodParser) Parse(r io.Reader) (methods []string, batch bool, err err if err := expectEOF(dec, lr); err != nil { return nil, false, classifyErr(err, lr) } + if probeLimitExceeded(lr) { + return nil, false, ErrProbeLimit + } return []string{method}, false, nil case '[': out, err := readBatchMethods(dec) @@ -94,12 +101,19 @@ func (p *MethodParser) Parse(r io.Reader) (methods []string, batch bool, err err if err := expectEOF(dec, lr); err != nil { return nil, true, classifyErr(err, lr) } + if probeLimitExceeded(lr) { + return nil, true, ErrProbeLimit + } return out, true, nil default: return nil, false, ErrNotObject } } +func probeLimitExceeded(lr *io.LimitedReader) bool { + return lr.N <= 0 +} + // expectEOF confirms the decoder has consumed the whole body, so we can reject // trailing non-whitespace data. func expectEOF(dec *json.Decoder, lr *io.LimitedReader) error { @@ -202,7 +216,7 @@ func skipValue(dec *json.Decoder) error { } // classifyErr maps a decoder error to ErrProbeLimit when it was caused by the -// probe budget being exhausted, and otherwise returns it wrapped. +// body exceeding MaxProbeBytes, and otherwise returns it wrapped. func classifyErr(err error, lr *io.LimitedReader) error { if err == nil { return nil diff --git a/ratelimiter/method_parser_test.go b/ratelimiter/method_parser_test.go index 024dcd936a..989156f0ab 100644 --- a/ratelimiter/method_parser_test.go +++ b/ratelimiter/method_parser_test.go @@ -234,24 +234,39 @@ func TestParse_ExactProbeLimitBodyParses(t *testing.T) { require.Equal(t, []string{"a"}, methods) } -// --- probe limit / partial read --- +// --- body size limit (MaxProbeBytes) --- func TestParse_ProbeLimitExceeded(t *testing.T) { - // "method" sits after a params array larger than the probe budget. + // "method" sits after a params array larger than MaxProbeBytes. body := `{"params":[` + strings.Repeat("1,", 500) + `1],"method":"eth_call"}` _, _, err := NewMethodParser(64).Parse(strings.NewReader(body)) require.ErrorIs(t, err, ErrProbeLimit) + require.True(t, IsBodyTooLarge(err)) } func TestParse_LargeTrailingParamsHitProbeLimit(t *testing.T) { - // The whole object is read (bounded by the probe) so a trailing duplicate - // "method" can be rejected; a params array larger than a tiny probe therefore + // The whole object is read (bounded by MaxProbeBytes) so a trailing duplicate + // "method" can be rejected; a params array larger than a tiny limit therefore // yields ErrProbeLimit even though "method" appears first. body := `{"method":"eth_call","params":[` + strings.Repeat("9,", 100_000) + `9]}` _, _, err := NewMethodParser(128).Parse(strings.NewReader(body)) require.ErrorIs(t, err, ErrProbeLimit) } +func TestParse_DuplicateMethodBeyondBodyLimitRejected(t *testing.T) { + padding := strings.Repeat("0", 200) + body := `{"method":"cheap","params":["` + padding + `"],"method":"expensive"}` + _, _, err := NewMethodParser(128).Parse(strings.NewReader(body)) + require.ErrorIs(t, err, ErrProbeLimit) + require.True(t, IsBodyTooLarge(err)) +} + +func TestParse_BodyOneByteOverLimitRejected(t *testing.T) { + body := `{"method":"eth_call","id":1}` + _, _, err := NewMethodParser(int64(len(body) - 1)).Parse(strings.NewReader(body)) + require.ErrorIs(t, err, ErrProbeLimit) +} + func TestParse_DefaultProbeLimitApplied(t *testing.T) { require.Equal(t, int64(DefaultMaxProbeBytes), NewMethodParser(0).maxProbeBytes) require.Equal(t, int64(DefaultMaxProbeBytes), NewMethodParser(-5).maxProbeBytes) From 4d7801b9d7a8b958f99103140c948964d327a004 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Thu, 30 Jul 2026 11:27:38 +0200 Subject: [PATCH 05/14] Changed default rate limit to disabled --- evmrpc/config/config.go | 2 +- evmrpc/config/config_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/evmrpc/config/config.go b/evmrpc/config/config.go index 5ab2260b16..0c5e6fb8f2 100644 --- a/evmrpc/config/config.go +++ b/evmrpc/config/config.go @@ -331,7 +331,7 @@ var DefaultConfig = Config{ TraceBakeSnapshotWindow: 64, IPRateLimitRPS: 200, IPRateLimitBurst: 400, - RateLimitingEnabled: true, + RateLimitingEnabled: false, TrustedProxyCIDRs: nil, BatchRequestLimit: 1000, BatchResponseMaxSize: 25 * 1000 * 1000, // 25MB diff --git a/evmrpc/config/config_test.go b/evmrpc/config/config_test.go index 4e060c09d4..b722544871 100644 --- a/evmrpc/config/config_test.go +++ b/evmrpc/config/config_test.go @@ -646,7 +646,7 @@ func TestReadConfigMaxSubscriptionsLogs(t *testing.T) { func TestReadConfigRateLimiting(t *testing.T) { cfg, err := config.ReadConfig(&opts{}) require.NoError(t, err) - require.True(t, cfg.RateLimitingEnabled) + require.False(t, cfg.RateLimitingEnabled) require.Nil(t, cfg.TrustedProxyCIDRs) require.Equal(t, config.DefaultConfig.IPRateLimitRPS, cfg.IPRateLimitRPS) require.Equal(t, config.DefaultConfig.IPRateLimitBurst, cfg.IPRateLimitBurst) From 71af9f4870737f1c293a0a1f104a4fbece7889f4 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Thu, 30 Jul 2026 11:41:14 +0200 Subject: [PATCH 06/14] Updated doc comments --- evmrpc/config/config.go | 22 +++++++++++++++------- evmrpc/rate_limit.go | 2 ++ ratelimiter/registry.go | 5 +++-- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/evmrpc/config/config.go b/evmrpc/config/config.go index 0c5e6fb8f2..39930ee882 100644 --- a/evmrpc/config/config.go +++ b/evmrpc/config/config.go @@ -237,14 +237,19 @@ type Config struct { TraceBakeSnapshotWindow int64 `mapstructure:"trace_bake_snapshot_window"` // recent snapshots to keep (default 64) // IPRateLimitRPS is the per-IP sustained request rate in requests/second. - // Zero disables per-IP rate limiting (all requests pass through). + // 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. + // 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. IPRateLimitBurst int `mapstructure:"ip_rate_limit_burst"` - // RateLimitingEnabled is the master switch for per-IP JSON-RPC rate limiting on - // the EVM HTTP plane. If disabled, requests bypass the rate limiter entirely. + // RateLimitingEnabled is the master switch for the rate-limit admission + // middleware on the EVM 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 @@ -934,14 +939,17 @@ trace_bake_use_snapshot = {{ .EVM.TraceBakeUseSnapshot }} trace_bake_snapshot_window = {{ .EVM.TraceBakeSnapshotWindow }} # ip_rate_limit_rps is the per-IP sustained request rate in requests/second. -# Set to 0 to disable per-IP rate limiting (all requests pass through). +# Set to 0 to disable per-IP throttling (no HTTP 429). Does not bypass the +# admission middleware; set rate_limiting_enabled = false for a full bypass. ip_rate_limit_rps = {{ .EVM.IPRateLimitRPS }} # ip_rate_limit_burst is the maximum per-IP burst above the sustained rate. +# Set to 0 to disable per-IP throttling (same effect as ip_rate_limit_rps = 0). ip_rate_limit_burst = {{ .EVM.IPRateLimitBurst }} -# rate_limiting_enabled is the master switch for per-IP JSON-RPC rate limiting on -# the EVM HTTP plane (:8545). +# rate_limiting_enabled is the master switch for the rate-limit admission +# middleware on the EVM HTTP plane (:8545). When false, requests bypass method +# extraction and all rejections from that layer (HTTP 400/413/429). rate_limiting_enabled = {{ .EVM.RateLimitingEnabled }} # trusted_proxy_cidrs lists CIDRs whose X-Forwarded-For headers are trusted when diff --git a/evmrpc/rate_limit.go b/evmrpc/rate_limit.go index d03021b4fc..39cd08b5ad 100644 --- a/evmrpc/rate_limit.go +++ b/evmrpc/rate_limit.go @@ -10,6 +10,8 @@ import ( // 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 diff --git a/ratelimiter/registry.go b/ratelimiter/registry.go index ae40e1964f..d68d02757b 100644 --- a/ratelimiter/registry.go +++ b/ratelimiter/registry.go @@ -46,10 +46,11 @@ var DefaultTrustedProxyCIDRs = []string{ // Config holds the configuration for a Registry type Config struct { // RPS is the sustained request rate allowed per IP in requests/second. - // Zero disables per-IP rate limiting (all requests pass). + // Zero disables the token bucket (Allow always returns true). Callers may + // still perform method extraction and other admission checks independently. RPS float64 // Burst is the maximum number of requests allowed in a single burst. - // Zero disables per-IP rate limiting (all requests pass). + // Zero disables the token bucket (same effect as RPS=0). Burst int // TrustedProxyCIDRs lists CIDRs whose X-Forwarded-For headers are trusted. // Empty means trust no proxy; use RemoteAddr / peer address directly. From 7267e39a78eeae294ac13d2f48363ed651ba4596 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Thu, 30 Jul 2026 11:52:33 +0200 Subject: [PATCH 07/14] Updated batch request limit to match ip burst limit --- evmrpc/config/config.go | 10 +++++++--- evmrpc/config/config_test.go | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/evmrpc/config/config.go b/evmrpc/config/config.go index 39930ee882..32fde81ada 100644 --- a/evmrpc/config/config.go +++ b/evmrpc/config/config.go @@ -244,7 +244,8 @@ type Config 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. + // admission middleware when rate_limiting_enabled is true. Should be at least + // batch_request_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 @@ -284,6 +285,8 @@ type Config struct { MaxOpenConnections int `mapstructure:"max_open_connections"` } +const defaultBatchRequestLimit = 1000 + var DefaultConfig = Config{ HTTPEnabled: true, HTTPPort: 8545, @@ -335,10 +338,10 @@ var DefaultConfig = Config{ TraceBakeUseSnapshot: false, TraceBakeSnapshotWindow: 64, IPRateLimitRPS: 200, - IPRateLimitBurst: 400, + IPRateLimitBurst: defaultBatchRequestLimit, RateLimitingEnabled: false, TrustedProxyCIDRs: nil, - BatchRequestLimit: 1000, + BatchRequestLimit: defaultBatchRequestLimit, BatchResponseMaxSize: 25 * 1000 * 1000, // 25MB MaxRequestBodyBytes: 5 * 1024 * 1024, // 5 MiB (matches go-ethereum rpc default body limit) MaxConcurrentRequestBytes: 128 * 1024 * 1024, // 128 MiB of request bodies admitted concurrently @@ -945,6 +948,7 @@ ip_rate_limit_rps = {{ .EVM.IPRateLimitRPS }} # ip_rate_limit_burst is the maximum per-IP burst above the sustained rate. # Set to 0 to disable per-IP throttling (same effect as ip_rate_limit_rps = 0). +# Should be at least batch_request_limit (one token is charged per batch element). ip_rate_limit_burst = {{ .EVM.IPRateLimitBurst }} # rate_limiting_enabled is the master switch for the rate-limit admission diff --git a/evmrpc/config/config_test.go b/evmrpc/config/config_test.go index b722544871..a2c74c6680 100644 --- a/evmrpc/config/config_test.go +++ b/evmrpc/config/config_test.go @@ -255,7 +255,7 @@ func getDefaultOpts() opts { 32, 1000, 200.0, - 400, + 1000, nil, nil, 1000, @@ -650,6 +650,7 @@ func TestReadConfigRateLimiting(t *testing.T) { require.Nil(t, cfg.TrustedProxyCIDRs) require.Equal(t, config.DefaultConfig.IPRateLimitRPS, cfg.IPRateLimitRPS) require.Equal(t, config.DefaultConfig.IPRateLimitBurst, cfg.IPRateLimitBurst) + require.GreaterOrEqual(t, cfg.IPRateLimitBurst, cfg.BatchRequestLimit) o := getDefaultOpts() o.rateLimitingEnabled = false From 352475f03e245e0b98eb504f87f94f8965903f59 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Thu, 30 Jul 2026 12:13:12 +0200 Subject: [PATCH 08/14] Addressed feedback about error for oversized bodies --- evmrpc/rate_limit_middleware.go | 15 ++++++++-- evmrpc/rate_limit_middleware_test.go | 43 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/evmrpc/rate_limit_middleware.go b/evmrpc/rate_limit_middleware.go index c0797e0dc0..396b82dfeb 100644 --- a/evmrpc/rate_limit_middleware.go +++ b/evmrpc/rate_limit_middleware.go @@ -32,12 +32,11 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) ip := m.gate.registry.IPFromHTTPRequest(r) body, err := readBoundedBody(r.Body, m.gate.maxBodyBytes) if err != nil { - if errors.Is(err, errBodyTooLarge) { + if isRequestBodyTooLarge(err) { recordRequestRejected(r.Context(), rejectReasonOversize) http.Error(w, "request body too large", http.StatusRequestEntityTooLarge) return } - recordRequestRejected(r.Context(), rejectReasonUnparseable) http.Error(w, "bad request", http.StatusBadRequest) return } @@ -84,3 +83,15 @@ func readBoundedBody(body io.ReadCloser, maxBytes int64) ([]byte, error) { } return buf, nil } + +// isRequestBodyTooLarge reports whether err indicates the request body exceeded +// maxBodyBytes. The outer requestSizeLimiter wraps r.Body in http.MaxBytesReader, +// which returns *http.MaxBytesError; readBoundedBody returns errBodyTooLarge when +// the gate runs without that wrapper. +func isRequestBodyTooLarge(err error) bool { + if errors.Is(err, errBodyTooLarge) { + return true + } + var maxErr *http.MaxBytesError + return errors.As(err, &maxErr) +} diff --git a/evmrpc/rate_limit_middleware_test.go b/evmrpc/rate_limit_middleware_test.go index 38a7deb9ce..d690a39034 100644 --- a/evmrpc/rate_limit_middleware_test.go +++ b/evmrpc/rate_limit_middleware_test.go @@ -254,6 +254,49 @@ func TestComposedStack_OversizeContentLengthBeforeProbeRead(t *testing.T) { require.Equal(t, int64(0), tracked.drained, "oversize body must be rejected before probe read") } +func TestComposedStack_ChunkedOversizeReturns413(t *testing.T) { + const maxBody = 100 + reg := mustRateLimitRegistry(t, 100, 10) + gate := NewRateLimitGate(reg, maxBody, true, "evm") + + var innerRan bool + base := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + innerRan = true + w.WriteHeader(http.StatusOK) + }) + stack := newRequestSizeLimiter(newRateLimitMiddleware(base, gate), maxBody, 0) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("x", maxBody+64))) + req.ContentLength = -1 + + rec := httptest.NewRecorder() + stack.ServeHTTP(rec, req) + + require.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) + require.Contains(t, rec.Body.String(), "request body too large") + require.False(t, innerRan) +} + +func TestRateLimitMiddleware_MaxBytesReaderOversizeReturns413(t *testing.T) { + const maxBody = 100 + reg := mustRateLimitRegistry(t, 100, 10) + gate := NewRateLimitGate(reg, maxBody, true, "evm") + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("inner should not be called") + }) + h := newRateLimitMiddleware(inner, gate) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("x", maxBody+64))) + req.Body = http.MaxBytesReader(rec, req.Body, maxBody) + req.ContentLength = -1 + + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) + 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") From 459b3baf87bc40d3ea5e021aaaae7a85273f7a43 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Thu, 30 Jul 2026 12:21:58 +0200 Subject: [PATCH 09/14] Added debug log for rejection method --- evmrpc/rate_limit_middleware.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evmrpc/rate_limit_middleware.go b/evmrpc/rate_limit_middleware.go index 396b82dfeb..e844f2cb74 100644 --- a/evmrpc/rate_limit_middleware.go +++ b/evmrpc/rate_limit_middleware.go @@ -42,7 +42,7 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) } r.Body = io.NopCloser(bytes.NewReader(body)) - allowed, _, checkErr := m.gate.Check(r.Context(), ip, bytes.NewReader(body)) + allowed, rejectMethod, checkErr := m.gate.Check(r.Context(), ip, bytes.NewReader(body)) if checkErr != nil { if ratelimiter.IsBodyTooLarge(checkErr) { recordRequestRejected(r.Context(), rejectReasonOversize) @@ -54,6 +54,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) recordRequestRejected(r.Context(), rejectReasonRateLimited) http.Error(w, "too many requests", http.StatusTooManyRequests) return From b7e1d18df1cbb196d88fbaaffb07e9a5d35c5c6e Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Thu, 30 Jul 2026 12:28:25 +0200 Subject: [PATCH 10/14] Removed unused httpserver rate limit registry --- evmrpc/rpcstack.go | 6 ------ evmrpc/server.go | 1 - 2 files changed, 7 deletions(-) diff --git a/evmrpc/rpcstack.go b/evmrpc/rpcstack.go index 8a60be5575..7b0c61c8cf 100644 --- a/evmrpc/rpcstack.go +++ b/evmrpc/rpcstack.go @@ -35,7 +35,6 @@ 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" ) @@ -104,8 +103,6 @@ type HTTPServer struct { // Zero (the default) disables the limit. maxOpenConns int - rateLimitRegistry *ratelimiter.Registry - handlerNames map[string]string } @@ -364,9 +361,6 @@ func (h *HTTPServer) EnableRPC(apis []rpc.API, config HTTPConfig) error { config.maxRequestBodyBytes, config.maxConcurrentRequestBytes, ) - if config.rateLimitGate != nil && h.rateLimitRegistry == nil { - h.rateLimitRegistry = config.rateLimitGate.registry - } h.httpHandler.Store(&rpcHandler{ Handler: handler, server: srv, diff --git a/evmrpc/server.go b/evmrpc/server.go index 8ea5217583..abdeb66138 100644 --- a/evmrpc/server.go +++ b/evmrpc/server.go @@ -239,7 +239,6 @@ func NewEVMHTTPServer( if err != nil { return nil, fmt.Errorf("evm rate limiter: %w", err) } - httpServer.rateLimitRegistry = rateLimitRegistry httpConfig.rateLimitGate = NewRateLimitGate( rateLimitRegistry, config.MaxRequestBodyBytes, From 856f4b4bb30b4cc7b429626d6cf6b827133ce3b5 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Thu, 30 Jul 2026 12:31:18 +0200 Subject: [PATCH 11/14] Updated method namespace metric attribute --- ratelimiter/registry.go | 4 ++-- ratelimiter/registry_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ratelimiter/registry.go b/ratelimiter/registry.go index d68d02757b..4b324f19cd 100644 --- a/ratelimiter/registry.go +++ b/ratelimiter/registry.go @@ -88,7 +88,7 @@ func New(cfg Config) (*Registry, error) { } // Allow reports whether the request from ip should be allowed for the given plane. -// Rejections increment rpc_rate_limit_rejected_total{plane, method}. +// Rejections increment rpc_rate_limit_rejected_total{plane, method_namespace}. func (r *Registry) Allow(ctx context.Context, ip, plane, method string) bool { if r.cfg.RPS <= 0 || r.cfg.Burst <= 0 { return true @@ -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", bucketRPCMethod(method)), + attribute.String("method_namespace", bucketRPCMethod(method)), ), ) return false diff --git a/ratelimiter/registry_test.go b/ratelimiter/registry_test.go index 44a44841fc..319a381942 100644 --- a/ratelimiter/registry_test.go +++ b/ratelimiter/registry_test.go @@ -106,7 +106,7 @@ func TestAllow_RejectionRecordsMethodMetric(t *testing.T) { require.Equal(t, int64(1), sum.DataPoints[0].Value) attrs := sum.DataPoints[0].Attributes.ToSlice() require.Contains(t, attrs, attribute.String("plane", "evm")) - require.Contains(t, attrs, attribute.String("method", "eth")) + require.Contains(t, attrs, attribute.String("method_namespace", "eth")) found = true } } From c7edbde2236f29a5aa818f95f4fff34ea2d1ab6e Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Thu, 30 Jul 2026 13:17:57 +0200 Subject: [PATCH 12/14] Added validation for batch limit --- evmrpc/config/config.go | 11 ++++++++++- evmrpc/config/config_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/evmrpc/config/config.go b/evmrpc/config/config.go index 32fde81ada..d4be053b21 100644 --- a/evmrpc/config/config.go +++ b/evmrpc/config/config.go @@ -690,6 +690,14 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { return cfg, fmt.Errorf("%s must be >= 0 (0 disables the limit), got %d", flagMaxOpenConnections, cfg.MaxOpenConnections) } } + if cfg.RateLimitingEnabled && cfg.IPRateLimitBurst > 0 && cfg.BatchRequestLimit > 0 && + cfg.IPRateLimitBurst < cfg.BatchRequestLimit { + return cfg, fmt.Errorf( + "%s (%d) must be >= %s (%d): the rate limiter charges one token per batch element, "+ + "so a lower burst would permanently reject any full-size batch", + flagIPRateLimitBurst, cfg.IPRateLimitBurst, flagBatchRequestLimit, cfg.BatchRequestLimit, + ) + } return cfg, nil } @@ -948,7 +956,8 @@ ip_rate_limit_rps = {{ .EVM.IPRateLimitRPS }} # ip_rate_limit_burst is the maximum per-IP burst above the sustained rate. # Set to 0 to disable per-IP throttling (same effect as ip_rate_limit_rps = 0). -# Should be at least batch_request_limit (one token is charged per batch element). +# Must be at least batch_request_limit when both are positive and rate_limiting_enabled +# is true (one token is charged per batch element) - enforced at startup. ip_rate_limit_burst = {{ .EVM.IPRateLimitBurst }} # rate_limiting_enabled is the master switch for the rate-limit admission diff --git a/evmrpc/config/config_test.go b/evmrpc/config/config_test.go index a2c74c6680..3195657637 100644 --- a/evmrpc/config/config_test.go +++ b/evmrpc/config/config_test.go @@ -665,3 +665,38 @@ func TestReadConfigRateLimiting(t *testing.T) { require.Equal(t, cfg.IPRateLimitBurst, rlCfg.Burst) require.Equal(t, cfg.TrustedProxyCIDRs, rlCfg.TrustedProxyCIDRs) } + +func TestReadConfigRateLimitingBurstBelowBatchLimitRejected(t *testing.T) { + // A burst below batch_request_limit would permanently reject full-size + // batches (one token is charged per batch element), so ReadConfig refuses + // it outright rather than letting an operator discover it in production. + o := getDefaultOpts() + o.rateLimitingEnabled = true + o.ipRateLimitBurst = 400 + o.batchRequestLimit = 1000 + _, err := config.ReadConfig(&o) + require.Error(t, err) + + // Equal burst and batch limit is allowed. + o.ipRateLimitBurst = 1000 + _, err = config.ReadConfig(&o) + require.NoError(t, err) + + // The check is skipped when rate limiting is disabled... + o.rateLimitingEnabled = false + o.ipRateLimitBurst = 400 + _, err = config.ReadConfig(&o) + require.NoError(t, err) + + // ...when the token bucket itself is disabled (burst <= 0)... + o.rateLimitingEnabled = true + o.ipRateLimitBurst = 0 + _, err = config.ReadConfig(&o) + require.NoError(t, err) + + // ...and when the batch limit is unbounded (batch_request_limit <= 0). + o.ipRateLimitBurst = 400 + o.batchRequestLimit = 0 + _, err = config.ReadConfig(&o) + require.NoError(t, err) +} From 9054c1198d5477bc78d3678f36b5122659e630a4 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 31 Jul 2026 12:44:31 +0200 Subject: [PATCH 13/14] Fixed config test --- evmrpc/config/config_fuzz_test.go | 2 ++ evmrpc/config/testdata/evm.golden | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/evmrpc/config/config_fuzz_test.go b/evmrpc/config/config_fuzz_test.go index 1b21dcf8da..81c5f29769 100644 --- a/evmrpc/config/config_fuzz_test.go +++ b/evmrpc/config/config_fuzz_test.go @@ -89,6 +89,8 @@ var evmKeys = []configtest.KeySpec{ {Key: "evm.trace_bake_snapshot_window", Path: "TraceBakeSnapshotWindow", Cast: configtest.CastInt64, Checked: true}, {Key: "evm.ip_rate_limit_rps", Path: "IPRateLimitRPS", Cast: configtest.CastFloat64, Checked: true}, {Key: "evm.ip_rate_limit_burst", Path: "IPRateLimitBurst", Cast: configtest.CastInt, Checked: true}, + {Key: "evm.rate_limiting_enabled", Path: "RateLimitingEnabled", Cast: configtest.CastBool, Checked: true}, + {Key: "evm.trusted_proxy_cidrs", Path: "TrustedProxyCIDRs", Cast: configtest.CastStringSlice, Checked: true}, {Key: "evm.batch_request_limit", Path: "BatchRequestLimit", Cast: configtest.CastInt, Checked: true}, {Key: "evm.batch_response_max_size", Path: "BatchResponseMaxSize", Cast: configtest.CastInt, Checked: true}, {Key: "evm.max_request_body_bytes", Path: "MaxRequestBodyBytes", Cast: configtest.CastInt64, Checked: true}, diff --git a/evmrpc/config/testdata/evm.golden b/evmrpc/config/testdata/evm.golden index d75b19fb1a..1bf682f6ad 100644 --- a/evmrpc/config/testdata/evm.golden +++ b/evmrpc/config/testdata/evm.golden @@ -51,7 +51,9 @@ TraceBakeWindowBlocks = int64(0) TraceBakeUseSnapshot = bool(false) TraceBakeSnapshotWindow = int64(64) IPRateLimitRPS = float64(200) -IPRateLimitBurst = int(400) +IPRateLimitBurst = int(1000) +RateLimitingEnabled = bool(false) +TrustedProxyCIDRs = BatchRequestLimit = int(1000) BatchResponseMaxSize = int(25000000) MaxRequestBodyBytes = int64(5242880) From 1d004a3d24c56cbf3b2cf3de6d8d641296e5b8f6 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 31 Jul 2026 13:03:46 +0200 Subject: [PATCH 14/14] Added parsing error charging rate limit bucket --- evmrpc/rate_limit.go | 7 ++++++- ratelimiter/method_bucket.go | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/evmrpc/rate_limit.go b/evmrpc/rate_limit.go index 39cd08b5ad..5ac70cb66b 100644 --- a/evmrpc/rate_limit.go +++ b/evmrpc/rate_limit.go @@ -41,7 +41,9 @@ func NewRateLimitGate(registry *ratelimiter.Registry, maxBodyBytes int64, enable // Check parses body for JSON-RPC method names and applies per-IP rate limits. // rejectMethod is the method that exhausted the bucket when allowed=false. -// Any Parse error, including ErrProbeLimit, is returned to the caller for rejection. +// 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 @@ -49,6 +51,9 @@ func (g *RateLimitGate) Check(ctx context.Context, ip string, body io.Reader) (a 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 } diff --git a/ratelimiter/method_bucket.go b/ratelimiter/method_bucket.go index 6201decdfa..480a0094c0 100644 --- a/ratelimiter/method_bucket.go +++ b/ratelimiter/method_bucket.go @@ -3,8 +3,10 @@ package ratelimiter import "strings" const ( - // rpcMethodBucketOther is the fallback label for unrecognized or malformed methods. + // rpcMethodBucketOther is the fallback label for unrecognized methods. rpcMethodBucketOther = "other" + // MethodInvalid labels rate-limit charges for unparseable request bodies. + MethodInvalid = "invalid" // maxRPCMethodLen rejects oversized method strings before metric recording. maxRPCMethodLen = 128 ) @@ -32,6 +34,9 @@ var knownRPCNamespaces = map[string]struct{}{ // suitable for OTel/Prometheus metrics. Attacker-controlled method strings // collapse to rpcMethodBucketOther. func bucketRPCMethod(method string) string { + if method == MethodInvalid { + return MethodInvalid + } if method == "" || len(method) > maxRPCMethodLen { return rpcMethodBucketOther }