Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@ Ref: https://keepachangelog.com/en/1.0.0/

## Unreleased

### Features
* [#3948](https://github.com/sei-protocol/sei-chain/pull/3948) feat(query): origin-aware pagination scan limits on the ABCI/gRPC query path. Untrusted callers are capped at 10k store entries per paginator scan, and `limit` / `offset` above that cap are rejected before iteration; operators can relax the cap for trusted origins via `[query].trusted-cidrs` and `[query].trusted-scan-limit` in `app.toml`.

### Improvements
* [#3818](https://github.com/sei-protocol/sei-chain/pull/3818) feat(evmrpc): extend HTTP admission control (`max_request_body_bytes`, `max_concurrent_request_bytes`, `ws_admission_timeout`) to the WebSocket plane (:8546). WS oversize frames close with WebSocket close code 1009; budget-wait timeouts return JSON-RPC error `-32005` before the connection closes. `evmrpc_requests_rejected_total` gains a `protocol` label (`http` / `ws`).

### Upgrade guide
* [#3948](https://github.com/sei-protocol/sei-chain/pull/3948) **Query pagination scan limits.** Upgraded nodes will not have a `[query]` section in `app.toml` until one is added. With the default empty `trusted-cidrs`, every ABCI/gRPC query origin — including the operator's own CLI over localhost — receives a 10k cap on paginated queries: `limit` and `offset` above 10k are rejected, and a paginator that scans more than 10k store entries (for example `count_total=true` over a large token list, or wide `Paginate`/`FilteredPaginate` keeper queries) returns `InvalidArgument` until the caller is trusted. **Operators running indexers or internal tooling that paginate large stores should add the caller's IP or CIDR to `query.trusted-cidrs` before or immediately after upgrade**, and may raise `query.trusted-scan-limit` (default `100000`; `0` = unlimited for trusted origins only).
* **WebSocket frame size default drops from 10 MiB to 5 MiB.** Before this release, :8546 used a hardcoded 10 MiB frame cap. Both HTTP and WebSocket now share `[evm].max_request_body_bytes`, whose default is 5 MiB (`5242880`). WS clients that send frames in the 5-10 MiB range (large `eth_sendRawTransaction` batches, wide filter payloads, etc.) will be disconnected after upgrade unless the limit is raised. **Operators who relied on the old 10 MiB WS cap should set `max_request_body_bytes = 10485760` in `app.toml` before upgrading.** This also raises the HTTP body limit to 10 MiB. The exported `DefaultWebsocketMaxMessageSize` constant was removed; use the config knob instead.
* [#3927](https://github.com/sei-protocol/sei-chain/pull/3927) **Legacy Sei JSON-RPC and CLI removal.** Removes `sei_associate`, `sei_getBlockByHash`, `sei_getBlockByHashExcludeTraceFail`, `sei_getBlockTransactionCountByHash`, `sei_getBlockTransactionCountByNumber`, `sei_getEvmTx`, `sei_getFilterChanges`, `sei_getFilterLogs`, `sei_getLogs`, `sei_getTransactionByBlockHashAndIndex`, `sei_getTransactionByBlockNumberAndIndex`, `sei_getTransactionByHash`, `sei_getTransactionCount`, `sei_getTransactionErrorByHash`, `sei_getTransactionReceiptExcludeTraceFail`, `sei_getVMError`, `sei_newBlockFilter`, `sei_newFilter`, `sei_sign`, and `sei_uninstallFilter`. Use standard `eth_*` methods for EVM-originated data and `seid tx evm native-associate <custom-message> -y` for address association. There is no block- or filter-level replacement for discovering Cosmos-originated synthetic logs; clients that know the synthetic transaction hash can enable `sei_getTransactionReceipt`.

Expand Down
8 changes: 4 additions & 4 deletions sei-cosmos/baseapp/abci.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ func (app *BaseApp) Query(ctx context.Context, req *abci.RequestQuery) (res *abc
// handle gRPC routes first rather than calling splitPath because '/' characters
// are used as part of gRPC paths
if grpcHandler := app.grpcQueryRouter.Route(req.Path); grpcHandler != nil {
resp := app.handleQueryGRPC(grpcHandler, *req)
resp := app.handleQueryGRPC(ctx, grpcHandler, *req)
return &resp, nil
}

Expand Down Expand Up @@ -623,15 +623,15 @@ func (app *BaseApp) ApplySnapshotChunk(context context.Context, req *abci.Reques
}
}

func (app *BaseApp) handleQueryGRPC(handler GRPCQueryHandler, req abci.RequestQuery) abci.ResponseQuery {
ctx, err := app.CreateQueryContext(req.Height, req.Prove)
func (app *BaseApp) handleQueryGRPC(ctx context.Context, handler GRPCQueryHandler, req abci.RequestQuery) abci.ResponseQuery {
sdkCtx, err := app.CreateQueryContext(req.Height, req.Prove)
if err != nil {
return sdkerrors.QueryResultWithDebug(err, app.trace)
}

// Only Cosmos ABCI gRPC queries may use client-facing pagination semantics.
// Historical EVM RPC also calls CreateQueryContext and must remain unmarked.
res, err := handler(ctx.WithIsABCIQuery(true), req)
res, err := handler(app.enrichABCIQueryContext(ctx, sdkCtx), req)
if err != nil {
res = sdkerrors.QueryResultWithDebug(gRPCErrorToSDKError(err), app.trace)
res.Height = req.Height
Expand Down
12 changes: 12 additions & 0 deletions sei-cosmos/baseapp/baseapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@ type BaseApp struct {
concurrencyWorkers int
occEnabled bool

queryConfig config.QueryConfig
trustedOriginMatcher *trustedCIDRMatcher

deliverTxHooks []DeliverTxHook

execProcessProposalMs int64
Expand Down Expand Up @@ -320,6 +323,15 @@ func NewBaseApp(
app.concurrencyWorkers = config.DefaultConcurrencyWorkers
}

queryCfg, err := readQueryConfig(appOpts)
if err != nil {
panic(err)
Comment thread
amir-deris marked this conversation as resolved.
}
warnQueryConfig(queryCfg)
matcher := newTrustedCIDRMatcher(queryCfg.TrustedCIDRs)
app.queryConfig = queryCfg
app.trustedOriginMatcher = matcher

return app
}

Expand Down
54 changes: 54 additions & 0 deletions sei-cosmos/baseapp/config_fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -584,3 +584,57 @@ func panicsNot(t *testing.T, fn func()) (ok bool) {
fn()
return true
}

// queryKeys covers the [query] keys readQueryConfig reads during BaseApp construction.
//
// Spelled as literals rather than through FlagQueryTrustedCIDRs so a constant rename fails
// CheckKeyNames rather than moving the row with the reader. Both reads are guarded and checked,
// matching ParseQueryConfig in sei-cosmos/server/config, but this manifest describes this reader.
var queryKeys = []configtest.KeySpec{
{
Key: "query.trusted-cidrs", Path: "TrustedCIDRs", Cast: configtest.CastStringSlice, Checked: true,
Why: "CIDR allowlist for relaxed query scan limits; empty means fail closed",
},
{
Key: "query.trusted-scan-limit", Path: "TrustedScanLimit", Cast: configtest.CastUint64, Checked: true,
Why: "max store entries a trusted-origin paginator may scan",
},
}

func readBaseAppQuery(opts configtest.AppOpts) (any, error) {
return readQueryConfig(opts)
}

func FuzzBaseAppQueryConfig(f *testing.F) {
seeds := configtest.NewSeeds(f, fuzzing.ConfigValue)
for i := range len(queryKeys) {
seeds.AddRow(uint(i), fuzzing.KindNil, "", int64(0), false)
seeds.AddRow(uint(i), fuzzing.KindString, "not-a-value", int64(0), false)
seeds.AddRow(uint(i), fuzzing.KindMap, "", int64(0), false)
}
seeds.AddRow(uint(0), fuzzing.KindStringSlice, "127.0.0.1/32", int64(0), false)
seeds.AddRow(uint(1), fuzzing.KindInt64, "", int64(250_000), false)

configtest.CheckEveryRowHasADiscriminatingSeed(f, "query", readBaseAppQuery, queryKeys, seeds)

f.Fuzz(func(t *testing.T, keyIdx uint, kind uint8, s string, n int64, b bool) {
spec := configtest.Pick(queryKeys, keyIdx)
configtest.CheckRow(t, "query", readBaseAppQuery, spec, fuzzing.ConfigValue(kind, s, n, b))
})
}

func TestBaseAppQueryAbsentKeysKeepDefaults(t *testing.T) {
configtest.CheckAbsent(t, "query", readBaseAppQuery, config.DefaultQueryConfig())
}

func TestBaseAppQueryDefaultsMatchTheRecordedValues(t *testing.T) {
configtest.CheckDefaults(t, "query", config.DefaultQueryConfig())
}

func TestBaseAppQueryKeyNamesMatchTheRecordedNames(t *testing.T) {
configtest.CheckKeyNames(t, "query", queryKeys)
}

func TestBaseAppQueryManifestNamesEveryField(t *testing.T) {
configtest.CheckManifestCoversEveryField(t, "query", config.DefaultQueryConfig(), queryKeys)
}
2 changes: 1 addition & 1 deletion sei-cosmos/baseapp/grpcserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func (app *BaseApp) RegisterGRPCServer(server gogogrpc.Server) {

// Direct Cosmos gRPC queries may use client-facing pagination semantics.
// Other CreateQueryContext consumers remain v6.6-compatible by default.
sdkCtx = sdkCtx.WithIsABCIQuery(true)
sdkCtx = app.enrichABCIQueryContext(grpcCtx, sdkCtx)
grpcCtx = context.WithValue(grpcCtx, sdk.SdkContextKey, sdkCtx)

md = metadata.Pairs(grpctypes.GRPCBlockHeightHeader, strconv.FormatInt(height, 10))
Expand Down
131 changes: 131 additions & 0 deletions sei-cosmos/baseapp/query_trust.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package baseapp

import (
"context"
"fmt"
"net"
"strings"

srvconfig "github.com/sei-protocol/sei-chain/sei-cosmos/server/config"
servertypes "github.com/sei-protocol/sei-chain/sei-cosmos/server/types"
sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types"
"github.com/sei-protocol/sei-chain/sei-cosmos/types/query"
rpctypes "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/jsonrpc/types"
"github.com/spf13/cast"
"google.golang.org/grpc/peer"
)

const (
FlagQueryTrustedCIDRs = "query.trusted-cidrs"
FlagQueryTrustedScanLimit = "query.trusted-scan-limit"
)

type trustedCIDRMatcher struct {
networks []*net.IPNet
}

// newTrustedCIDRMatcher returns a matcher for parseable entries in cidrs, skipping the rest.
// warnQueryConfig should run before this so skipped entries are logged.
func newTrustedCIDRMatcher(cidrs []string) *trustedCIDRMatcher {
networks := make([]*net.IPNet, 0, len(cidrs))
for _, cidr := range cidrs {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
continue
}
networks = append(networks, network)
}
return &trustedCIDRMatcher{networks: networks}
}

func (m *trustedCIDRMatcher) contains(ipStr string) bool {
if m == nil {
return false
}
ip := net.ParseIP(stripHostPort(ipStr))
if ip == nil {
return false
}
for _, network := range m.networks {
if network.Contains(ip) {
return true
}
}
return false
}

func readQueryConfig(appOpts servertypes.AppOptions) (srvconfig.QueryConfig, error) {
cfg := srvconfig.DefaultQueryConfig()
var err error

if v := appOpts.Get(FlagQueryTrustedCIDRs); v != nil {
if cfg.TrustedCIDRs, err = cast.ToStringSliceE(v); err != nil {
return cfg, fmt.Errorf("invalid %s: %w", FlagQueryTrustedCIDRs, err)
}
}
if v := appOpts.Get(FlagQueryTrustedScanLimit); v != nil {
if cfg.TrustedScanLimit, err = cast.ToUint64E(v); err != nil {
return cfg, fmt.Errorf("invalid %s: %w", FlagQueryTrustedScanLimit, err)
}
}
return cfg, nil
}

func warnQueryConfig(cfg srvconfig.QueryConfig) {
for _, warning := range srvconfig.ValidateQueryConfig(cfg) {
logger.Warn(warning)
}
}

func (app *BaseApp) enrichABCIQueryContext(ctx context.Context, sdkCtx sdk.Context) sdk.Context {
sdkCtx = sdkCtx.WithIsABCIQuery(true)
originIP := queryOriginIP(ctx)
trusted := app.trustedOriginMatcher != nil && app.trustedOriginMatcher.contains(originIP)
sdkCtx = sdkCtx.WithIsTrustedQueryOrigin(trusted)

if trusted {
if app.queryConfig.TrustedScanLimit == 0 {
sdkCtx = sdkCtx.WithQueryScanLimit(false, 0)
} else {
sdkCtx = sdkCtx.WithQueryScanLimit(true, app.queryConfig.TrustedScanLimit)
}
logger.Debug(
"query pagination using trusted scan limit",
"origin", originIP,
"limit", app.queryConfig.TrustedScanLimit,
)
return sdkCtx
}

return sdkCtx.WithQueryScanLimit(true, query.MaxScanLimit)
}

func queryOriginIP(ctx context.Context) string {
if callInfo := rpctypes.GetCallInfo(ctx); callInfo != nil {
if addr := callInfo.RemoteAddr(); addr != "" {
return addr
}
}
if p, ok := peer.FromContext(ctx); ok && p.Addr != nil {
return p.Addr.String()
}
return ""
}

func stripHostPort(addr string) string {
if addr == "" {
return ""
}
if strings.HasPrefix(addr, "[") {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return strings.Trim(addr, "[]")
}
return host
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
return host
}
81 changes: 81 additions & 0 deletions sei-cosmos/baseapp/query_trust_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package baseapp

import (
"net"
"testing"

srvconfig "github.com/sei-protocol/sei-chain/sei-cosmos/server/config"
sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types"
"github.com/sei-protocol/sei-chain/testutil/configtest"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/peer"
)

func TestTrustedCIDRMatcher(t *testing.T) {
matcher := newTrustedCIDRMatcher([]string{"127.0.0.1/32", "10.0.0.0/8"})
require.True(t, matcher.contains("127.0.0.1:54321"))
require.True(t, matcher.contains("10.1.2.3"))
require.False(t, matcher.contains("203.0.113.1"))
}

func TestTrustedCIDRMatcherSkipsInvalidEntries(t *testing.T) {
matcher := newTrustedCIDRMatcher([]string{"not-a-cidr", "127.0.0.1/32"})
require.True(t, matcher.contains("127.0.0.1"))
require.False(t, matcher.contains("203.0.113.1"))
}

func TestQueryOriginIPFromGRPCPeer(t *testing.T) {
addr, err := net.ResolveTCPAddr("tcp", "192.0.2.1:9090")
require.NoError(t, err)
ctx := peer.NewContext(t.Context(), &peer.Peer{Addr: addr})
require.Equal(t, "192.0.2.1:9090", queryOriginIP(ctx))
}

func TestValidateQueryConfigWarnsOnBroadCIDR(t *testing.T) {
warnings := srvconfig.ValidateQueryConfig(srvconfig.QueryConfig{
TrustedCIDRs: []string{"0.0.0.0/0"},
})
require.Len(t, warnings, 1)
require.Contains(t, warnings[0], "overly broad")
}

func TestStripHostPort(t *testing.T) {
require.Equal(t, "127.0.0.1", stripHostPort("127.0.0.1:9090"))
require.Equal(t, "2001:db8::1", stripHostPort("[2001:db8::1]:9090"))
}

func TestEnrichABCIQueryContextTrustedOriginUnlimitedScan(t *testing.T) {
app := newTestBaseApp(t, configtest.AppOpts{
FlagChainID: "sei-test",
FlagQueryTrustedCIDRs: []string{"127.0.0.1/32"},
FlagQueryTrustedScanLimit: uint64(0),
})

addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:9090")
require.NoError(t, err)
grpcCtx := peer.NewContext(t.Context(), &peer.Peer{Addr: addr})

sdkCtx := app.enrichABCIQueryContext(grpcCtx, sdk.Context{})
require.True(t, sdkCtx.IsABCIQuery())
require.True(t, sdkCtx.IsTrustedQueryOrigin())
require.False(t, sdkCtx.EnforceQueryScanLimit())
}

func TestEnrichABCIQueryContextTrustedOriginUsesConfiguredLimit(t *testing.T) {
const trustedLimit = uint64(250_000)

app := newTestBaseApp(t, configtest.AppOpts{
FlagChainID: "sei-test",
FlagQueryTrustedCIDRs: []string{"10.0.0.0/8"},
FlagQueryTrustedScanLimit: trustedLimit,
})

addr, err := net.ResolveTCPAddr("tcp", "10.1.2.3:9090")
require.NoError(t, err)
grpcCtx := peer.NewContext(t.Context(), &peer.Peer{Addr: addr})

sdkCtx := app.enrichABCIQueryContext(grpcCtx, sdk.Context{})
require.True(t, sdkCtx.IsTrustedQueryOrigin())
require.True(t, sdkCtx.EnforceQueryScanLimit())
require.Equal(t, trustedLimit, sdkCtx.QueryScanLimit())
}
2 changes: 2 additions & 0 deletions sei-cosmos/baseapp/testdata/query.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
TrustedCIDRs = <nil-slice>
TrustedScanLimit = uint64(100000)
3 changes: 3 additions & 0 deletions sei-cosmos/baseapp/testdata/query.keys.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"query.trusted-cidrs"
"query.trusted-scan-limit"
# keys with a target of their own
14 changes: 12 additions & 2 deletions sei-cosmos/server/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ type Config struct {
StateCommit config.StateCommitConfig `mapstructure:"state-commit"`
StateStore config.StateStoreConfig `mapstructure:"state-store"`
Genesis GenesisConfig `mapstructure:"genesis"`
Query QueryConfig `mapstructure:"query"`
}

// SetMinGasPrices sets the validator's minimum gas prices.
Expand Down Expand Up @@ -409,6 +410,7 @@ func DefaultConfig() *Config {
StreamImport: false,
GenesisStreamFile: "",
},
Query: DefaultQueryConfig(),
}
}

Expand Down Expand Up @@ -569,7 +571,7 @@ func GetConfig(v *viper.Viper) (Config, error) {
grpcMaxConnectionAge := clampNonNegativeDuration(v.GetDuration("grpc.max-connection-age"), DefaultGRPCMaxConnectionAge)
grpcMaxConnectionAgeGrace := clampNonNegativeDuration(v.GetDuration("grpc.max-connection-age-grace"), DefaultGRPCMaxConnectionAgeGrace)

return Config{
cfg := Config{
BaseConfig: BaseConfig{
MinGasPrices: v.GetString("minimum-gas-prices"),
InterBlockCache: v.GetBool("inter-block-cache"),
Expand Down Expand Up @@ -664,7 +666,15 @@ func GetConfig(v *viper.Viper) (Config, error) {
StreamImport: v.GetBool("genesis.stream-import"),
GenesisStreamFile: v.GetString("genesis.genesis-stream-file"),
},
}, nil
}

queryCfg, err := ParseQueryConfig(v)
if err != nil {
return Config{}, err
}
cfg.Query = queryCfg

return cfg, nil
}

// ValidateBasic validates the server configuration.
Expand Down
Loading
Loading