Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions ratelimiter/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
//
// # MethodParser fail-closed contract
//
// MethodParser.Parse must be used on the entire request body, bounded by
// MaxProbeBytes (see DefaultMaxProbeBytes). Callers must reject the request on
// MethodParser.Parse must be used on the entire request body. When MaxProbeBytes
// is positive the body must fit within that limit (see DefaultMaxProbeBytes for
// a typical cap); non-positive means no parser-imposed limit. Callers must reject on
// every returned error — including ErrProbeLimit — with no fallback decode and
// no default method bucket. Mapping any error to "admit anyway" defeats method
// extraction and allows rate-limit bypass (for example duplicate "method" keys
Expand Down
15 changes: 11 additions & 4 deletions ratelimiter/method_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ type MethodParser struct {
}

// NewMethodParser returns a MethodParser that accepts request bodies of at most
// maxProbeBytes bytes. Non-positive values use DefaultMaxProbeBytes.
// maxProbeBytes bytes. Non-positive values mean unlimited (no parser-imposed cap).
func NewMethodParser(maxProbeBytes int64) *MethodParser {
if maxProbeBytes <= 0 {
maxProbeBytes = DefaultMaxProbeBytes
maxProbeBytes = 0
}
if maxProbeBytes == math.MaxInt64 {
// Prevent overflow when constructing the LimitedReader budget (+1).
Expand All @@ -65,11 +65,18 @@ func NewMethodParser(maxProbeBytes int64) *MethodParser {
// Fail-closed contract: callers must reject on every returned error, including
// ErrProbeLimit. See the package doc for HTTP/RPC mapping guidance.
func (p *MethodParser) Parse(r io.Reader) (methods []string, batch bool, err error) {
if p.maxProbeBytes <= 0 {
dec := json.NewDecoder(r)
return parseMethods(dec, nil)
}
// N is maxProbeBytes+1 so that lr.N reaching 0 unambiguously means the body
// exceeded the budget.
lr := &io.LimitedReader{R: r, N: p.maxProbeBytes + 1}
dec := json.NewDecoder(lr)
return parseMethods(dec, lr)
}

func parseMethods(dec *json.Decoder, lr *io.LimitedReader) (methods []string, batch bool, err error) {
tok, err := dec.Token()
if err != nil {
return nil, false, classifyErr(err, lr)
Expand Down Expand Up @@ -108,7 +115,7 @@ func (p *MethodParser) Parse(r io.Reader) (methods []string, batch bool, err err
// trailing non-whitespace data.
func expectEOF(dec *json.Decoder, lr *io.LimitedReader) error {
if _, err := dec.Token(); err != nil {
if errors.Is(err, io.EOF) && lr.N > 0 {
if errors.Is(err, io.EOF) && (lr == nil || lr.N > 0) {
return nil
}
return err
Expand Down Expand Up @@ -211,7 +218,7 @@ func classifyErr(err error, lr *io.LimitedReader) error {
if err == nil {
return nil
}
if (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) && lr.N <= 0 {
if lr != nil && (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) && lr.N <= 0 {
return ErrProbeLimit
}
if errors.Is(err, ErrNoMethod) || errors.Is(err, ErrMethodNotString) ||
Expand Down
13 changes: 11 additions & 2 deletions ratelimiter/method_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,11 +268,20 @@ func TestParse_BodyOneByteOverLimitRejected(t *testing.T) {
}

func TestParse_DefaultProbeLimitApplied(t *testing.T) {
require.Equal(t, int64(DefaultMaxProbeBytes), NewMethodParser(0).maxProbeBytes)
require.Equal(t, int64(DefaultMaxProbeBytes), NewMethodParser(-5).maxProbeBytes)
require.Equal(t, int64(0), NewMethodParser(0).maxProbeBytes)
require.Equal(t, int64(0), NewMethodParser(-5).maxProbeBytes)
require.Equal(t, int64(256), NewMethodParser(256).maxProbeBytes)
}

func TestParse_UnlimitedProbeAcceptsLargeBody(t *testing.T) {
big := strings.Repeat("a", DefaultMaxProbeBytes)
body := `{"jsonrpc":"2.0","id":1,"params":["0x` + big + `"],"method":"broadcast_tx_sync"}`
methods, batch, err := NewMethodParser(0).Parse(strings.NewReader(body))
require.NoError(t, err)
require.False(t, batch)
require.Equal(t, []string{"broadcast_tx_sync"}, methods)
}

func TestParse_MaxInt64ProbeLimitClamped(t *testing.T) {
// math.MaxInt64 is clamped so the maxProbeBytes+1 sentinel in Parse cannot
// overflow to a negative LimitedReader budget.
Expand Down
43 changes: 43 additions & 0 deletions sei-tendermint/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import (
"strings"
"time"

"github.com/sei-protocol/sei-chain/ratelimiter"
mempoolcfg "github.com/sei-protocol/sei-chain/sei-tendermint/internal/mempool"
tmos "github.com/sei-protocol/sei-chain/sei-tendermint/libs/os"
"github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils"
rpctypes "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/jsonrpc/types"
"github.com/sei-protocol/sei-chain/sei-tendermint/types"
)

Expand Down Expand Up @@ -543,6 +545,29 @@ type RPCConfig struct {
// concurrent search load: it is shared across requests, not applied per-query.
// 0 disables the cap (not recommended on public nodes).
MaxSearchScanBudget int `mapstructure:"max-search-scan-budget"`

// IPRateLimitRPS is the per-IP sustained request rate in requests/second for
// CometBFT RPC HTTP (:26657). Zero disables the token bucket (no HTTP 429
// rejections). When rate-limiting-enabled is true, the admission middleware
// still runs: bodies are parsed and oversize/malformed requests are rejected
// before dispatch.
IPRateLimitRPS float64 `mapstructure:"ip-rate-limit-rps"`

// IPRateLimitBurst is the maximum per-IP burst size. Zero disables the token
// bucket (same effect as ip-rate-limit-rps = 0) and does not bypass the
// admission middleware when rate-limiting-enabled is true. Should be at least
// the JSON-RPC batch size limit because the rate limiter charges one token
// per batch element.
IPRateLimitBurst int `mapstructure:"ip-rate-limit-burst"`

// RateLimitingEnabled is the master switch for the rate-limit admission
// middleware on the CometBFT RPC HTTP plane. When false, requests bypass
// method extraction and all rejections from that layer (HTTP 400/413/429).
RateLimitingEnabled bool `mapstructure:"rate-limiting-enabled"`

// TrustedProxyCIDRs lists CIDRs whose X-Forwarded-For headers are trusted when
// resolving the client IP for rate limiting. Empty means trust no proxy.
TrustedProxyCIDRs []string `mapstructure:"trusted-proxy-cidrs"`
}

// DefaultRPCConfig returns a default configuration for the RPC server
Expand Down Expand Up @@ -578,6 +603,11 @@ func DefaultRPCConfig() *RPCConfig {

MaxTxSearchResults: 10_000,
MaxSearchScanBudget: 100_000,

IPRateLimitRPS: 200,
IPRateLimitBurst: 400,
RateLimitingEnabled: false,
TrustedProxyCIDRs: nil,
}
}

Expand Down Expand Up @@ -636,9 +666,22 @@ func (cfg *RPCConfig) ValidateBasic() error {
if cfg.MaxSearchScanBudget < 0 {
return errors.New("max-search-scan-budget can't be negative")
}
if cfg.RateLimitingEnabled && cfg.IPRateLimitBurst > 0 && cfg.IPRateLimitBurst < rpctypes.RequestBatchSizeLimit {
return fmt.Errorf("ip-rate-limit-burst (%d) must be >= %d: the rate limiter charges one token per batch element",
cfg.IPRateLimitBurst, rpctypes.RequestBatchSizeLimit)
}
return nil
}

// RateLimiterConfig builds the ratelimiter.Config used by CometBFT RPC HTTP admission.
func (cfg *RPCConfig) RateLimiterConfig() ratelimiter.Config {
return ratelimiter.Config{
RPS: cfg.IPRateLimitRPS,
Burst: cfg.IPRateLimitBurst,
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
}
}

// IsCorsEnabled returns true if cross-origin resource sharing is enabled.
func (cfg *RPCConfig) IsCorsEnabled() bool {
return len(cfg.CORSAllowedOrigins) != 0
Expand Down
26 changes: 26 additions & 0 deletions sei-tendermint/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

"github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils"
rpctypes "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/jsonrpc/types"
"github.com/sei-protocol/sei-chain/sei-tendermint/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -96,6 +97,15 @@ func TestRPCConfigValidateBasic(t *testing.T) {
assert.NoError(t, cfg2.ValidateBasic())
cfg2.TimeoutWrite = 0 // 0 disables; constraint does not apply
assert.NoError(t, cfg2.ValidateBasic())

cfg3 := TestRPCConfig()
cfg3.RateLimitingEnabled = true
cfg3.IPRateLimitBurst = rpctypes.RequestBatchSizeLimit - 1
assert.Error(t, cfg3.ValidateBasic())
cfg3.IPRateLimitBurst = rpctypes.RequestBatchSizeLimit
assert.NoError(t, cfg3.ValidateBasic())
cfg3.IPRateLimitBurst = 0
assert.NoError(t, cfg3.ValidateBasic())
}

func TestMempoolConfigValidateBasic(t *testing.T) {
Expand Down Expand Up @@ -325,3 +335,19 @@ func TestWalFile_BothExist_LegacyWins(t *testing.T) {
assert.Equal(t, expected, cfg.WalFile(),
"legacy should win when both locations exist")
}

func TestRPCRateLimitKeysKebabCase(t *testing.T) {
const body = `
[rpc]
ip-rate-limit-rps = 42.5
ip-rate-limit-burst = 50
rate-limiting-enabled = true
trusted-proxy-cidrs = ["10.0.0.0/8"]
`
conf, err := unmarshalConfigTOML(t, body)
require.NoError(t, err)
require.Equal(t, 42.5, conf.RPC.IPRateLimitRPS)
require.Equal(t, 50, conf.RPC.IPRateLimitBurst)
require.True(t, conf.RPC.RateLimitingEnabled)
require.Equal(t, []string{"10.0.0.0/8"}, conf.RPC.TrustedProxyCIDRs)
}
23 changes: 23 additions & 0 deletions sei-tendermint/config/toml.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,29 @@ max-tx-search-results = {{ .RPC.MaxTxSearchResults }}
# accumulate. Set to 0 to disable the cap (not recommended on public nodes).
max-search-scan-budget = {{ .RPC.MaxSearchScanBudget }}

# ip-rate-limit-rps is the per-IP sustained request rate in requests/second for
# CometBFT RPC HTTP (:26657). Zero disables the token bucket (no HTTP 429
# rejections). When rate-limiting-enabled is true, the admission middleware still
# runs: bodies are parsed and oversize/malformed requests are rejected before dispatch.
ip-rate-limit-rps = {{ .RPC.IPRateLimitRPS }}

# ip-rate-limit-burst is the maximum per-IP burst above the sustained rate.
# Zero disables the token bucket (same effect as ip-rate-limit-rps = 0) and does
# not bypass the admission middleware when rate-limiting-enabled is true. Must be
# at least the JSON-RPC batch size limit when both are positive and
# rate-limiting-enabled is true because the rate limiter charges one token per
# JSON-RPC batch element.
ip-rate-limit-burst = {{ .RPC.IPRateLimitBurst }}

# rate-limiting-enabled is the master switch for the rate-limit admission
# middleware on the CometBFT RPC HTTP plane. When false, requests bypass method
# extraction and all rejections from that layer (HTTP 400/413/429).
rate-limiting-enabled = {{ .RPC.RateLimitingEnabled }}

# trusted-proxy-cidrs lists CIDRs whose X-Forwarded-For headers are trusted when
# resolving the client IP for rate limiting. Empty means trust no proxy.
trusted-proxy-cidrs = [{{ range .RPC.TrustedProxyCIDRs }}{{ printf "%q, " . }}{{end}}]

#######################################################################
### P2P Configuration Options ###
#######################################################################
Expand Down
5 changes: 4 additions & 1 deletion sei-tendermint/internal/inspect/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ func (ins *Inspector) Run(ctx context.Context) error {
func startRPCServers(ctx context.Context, cfg *config.RPCConfig, routes rpccore.RoutesMap) error {
g, tctx := errgroup.WithContext(ctx)
listenAddrs := tmstrings.SplitAndTrimEmpty(cfg.ListenAddress, ",", " ")
rh := rpc.Handler(cfg, routes)
rh, err := rpc.Handler(cfg, routes)
if err != nil {
return err
}
for _, listenerAddr := range listenAddrs {
server := rpc.Server{
Config: cfg,
Expand Down
28 changes: 21 additions & 7 deletions sei-tendermint/internal/inspect/rpc/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package rpc

import (
"context"
"fmt"
"net/http"

"github.com/rs/cors"
"github.com/sei-protocol/seilog"

"github.com/sei-protocol/sei-chain/ratelimiter"
"github.com/sei-protocol/sei-chain/sei-tendermint/config"
"github.com/sei-protocol/sei-chain/sei-tendermint/internal/pubsub"
"github.com/sei-protocol/sei-chain/sei-tendermint/internal/rpc/core"
Expand Down Expand Up @@ -50,10 +52,10 @@ func Routes(cfg config.RPCConfig, s state.Store, bs state.BlockStore, es []index
}
}

// Handler returns the http.Handler configured for use with an Inspector server. Handler
// registers the routes on the http.Handler and also registers the websocket handler
// and the CORS handler if specified by the configuration options.
func Handler(rpcConfig *config.RPCConfig, routes core.RoutesMap) http.Handler {
// Handler returns the Inspector HTTP handler with routes, websocket, CORS, and
// the rate-limit gate when enabled. It returns an error if the rate limiter
// cannot be constructed.
func Handler(rpcConfig *config.RPCConfig, routes core.RoutesMap) (http.Handler, error) {
mux := http.NewServeMux()

var eventBus eventBusUnsubscriber
Expand All @@ -70,11 +72,23 @@ func Handler(rpcConfig *config.RPCConfig, routes core.RoutesMap) http.Handler {
mux.HandleFunc("/websocket", wm.WebsocketHandler)

server.RegisterRPCFuncs(mux, routes)
var rootHandler http.Handler = mux
var rateLimitGate *server.RateLimitGate
if rpcConfig.RateLimitingEnabled {
rateLimitRegistry, err := ratelimiter.New(rpcConfig.RateLimiterConfig())
if err != nil {
return nil, fmt.Errorf("rpc rate limiter: %w", err)
}
rateLimitGate = server.NewRateLimitGate(
rateLimitRegistry,
rpcConfig.MaxBodyBytes,
true,
)
}
rootHandler := server.NewRateLimitMiddleware(mux, rateLimitGate)
if rpcConfig.IsCorsEnabled() {
rootHandler = addCORSHandler(rpcConfig, mux)
rootHandler = addCORSHandler(rpcConfig, rootHandler)
}
return rootHandler
return rootHandler, nil
}

func addCORSHandler(rpcConfig *config.RPCConfig, h http.Handler) http.Handler {
Expand Down
29 changes: 29 additions & 0 deletions sei-tendermint/internal/inspect/rpc/rpc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package rpc

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/sei-protocol/sei-chain/sei-tendermint/config"
"github.com/sei-protocol/sei-chain/sei-tendermint/internal/rpc/core"
)

func TestHandler_InvalidTrustedProxyCIDRs(t *testing.T) {
cfg := config.DefaultRPCConfig()
cfg.RateLimitingEnabled = true
cfg.TrustedProxyCIDRs = []string{"not-a-cidr"}

h, err := Handler(cfg, core.RoutesMap{})
require.Error(t, err)
require.Nil(t, h)
}

func TestHandler_RateLimitingEnabled(t *testing.T) {
cfg := config.DefaultRPCConfig()
cfg.RateLimitingEnabled = true

h, err := Handler(cfg, core.RoutesMap{})
require.NoError(t, err)
require.NotNil(t, h)
}
18 changes: 16 additions & 2 deletions sei-tendermint/internal/rpc/core/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/rs/cors"
"github.com/sei-protocol/seilog"

"github.com/sei-protocol/sei-chain/ratelimiter"
"github.com/sei-protocol/sei-chain/sei-tendermint/config"
"github.com/sei-protocol/sei-chain/sei-tendermint/crypto"
"github.com/sei-protocol/sei-chain/sei-tendermint/internal/blocksync"
Expand Down Expand Up @@ -322,6 +323,19 @@ func (env *Environment) StartService(ctx context.Context, conf *config.Config) (
logger.Info("Event log subscription enabled")
}

var rateLimitGate *rpcserver.RateLimitGate
if conf.RPC.RateLimitingEnabled {
rateLimitRegistry, err := ratelimiter.New(conf.RPC.RateLimiterConfig())
if err != nil {
return nil, fmt.Errorf("rpc rate limiter: %w", err)
}
rateLimitGate = rpcserver.NewRateLimitGate(
rateLimitRegistry,
conf.RPC.MaxBodyBytes,
true,
)
}

// We may expose the RPC over both TCP and a Unix-domain socket.
listeners := make([]net.Listener, len(listenAddrs))
for i, listenAddr := range listenAddrs {
Expand Down Expand Up @@ -353,14 +367,14 @@ func (env *Environment) StartService(ctx context.Context, conf *config.Config) (
return nil, err
}

var rootHandler http.Handler = mux
rootHandler := rpcserver.NewRateLimitMiddleware(mux, rateLimitGate)
if conf.RPC.IsCorsEnabled() {
corsMiddleware := cors.New(cors.Options{
AllowedOrigins: conf.RPC.CORSAllowedOrigins,
AllowedMethods: conf.RPC.CORSAllowedMethods,
AllowedHeaders: conf.RPC.CORSAllowedHeaders,
})
rootHandler = corsMiddleware.Handler(mux)
rootHandler = corsMiddleware.Handler(rootHandler)
}
if conf.RPC.IsTLSEnabled() {
go func() {
Expand Down
4 changes: 1 addition & 3 deletions sei-tendermint/rpc/jsonrpc/server/http_json_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import (

// HTTP + JSON handler

const REQUEST_BATCH_SIZE_LIMIT = 10

// jsonrpc calls grab the given method's function info and runs reflect.Call
func makeJSONRPCHandler(funcMap map[string]*RPCFunc) http.HandlerFunc {
return func(w http.ResponseWriter, hreq *http.Request) {
Expand All @@ -41,7 +39,7 @@ func makeJSONRPCHandler(funcMap map[string]*RPCFunc) http.HandlerFunc {
}

requests, err := parseRequests(b)
if len(requests) > REQUEST_BATCH_SIZE_LIMIT {
if len(requests) > rpctypes.RequestBatchSizeLimit {
writeRPCResponse(w, rpctypes.RPCRequest{}.MakeErrorf(
rpctypes.CodeParseError, "Batch size limit exceeded."))
return
Expand Down
Loading
Loading