From ead463b9db87175194ba814c54fd08493e2903d7 Mon Sep 17 00:00:00 2001 From: "Masih H. Derkani" Date: Tue, 7 Jul 2026 15:20:08 +0100 Subject: [PATCH 1/3] Disable JS debug tracers by default with native tracer allowlist Add EVM RPC configuration for trace_allowed_tracers and trace_allow_js_tracers. The native tracer allowlist is validated at startup and remains native-only, while JavaScript tracer execution requires an explicit separate opt-in. Enforce the tracer policy across debug_traceCall, debug_traceTransaction, debug_traceBlockByNumber, debug_traceBlockByHash, and debug_traceTransactionProfile. Validate muxTracer nested configs as well so request-supplied JavaScript cannot bypass the top-level check through nested tracer names. Default the allowlist to the registered native tracer names and keep JS tracers disabled in generated and Docker configs. Add focused tests for config parsing, native allowlist behavior, JS opt-in behavior, and muxTracer nesting. --- docker/localnode/config/app.toml | 4 ++ docker/rpcnode/config/app.toml | 4 ++ evmrpc/config/config.go | 94 ++++++++++++++++++++++++++ evmrpc/config/config_test.go | 62 ++++++++++++++++++ evmrpc/trace_profile.go | 3 + evmrpc/tracers.go | 94 ++++++++++++++++++++++++-- evmrpc/tracers_allowlist_test.go | 109 +++++++++++++++++++++++++++++++ 7 files changed, 364 insertions(+), 6 deletions(-) create mode 100644 evmrpc/tracers_allowlist_test.go diff --git a/docker/localnode/config/app.toml b/docker/localnode/config/app.toml index 89ed5e9b22..dadff7f527 100644 --- a/docker/localnode/config/app.toml +++ b/docker/localnode/config/app.toml @@ -278,6 +278,10 @@ enable_test_api = true # Set to 0 to disable request limiter, otherwise this limits the number of concurrent simulation calls. max_concurrent_simulation_calls = 0 +# Native debug tracers allowed in TraceConfig.Tracer. JavaScript tracer source is disabled. +trace_allowed_tracers = ["callTracer", "prestateTracer", "flatCallTracer", "4byteTracer", "noopTracer", "muxTracer"] +trace_allow_js_tracers = false + # Legacy sei_* / sei2_* JSON-RPC (EVM HTTP only). # DEPRECATION: All sei_* and sei2_* methods are deprecated and scheduled for removal - no new integrations. # HTTP 200; gate errors: JSON-RPC error (data legacy_sei_deprecated). Success: unchanged body; optional header Sei-Legacy-RPC-Deprecation. diff --git a/docker/rpcnode/config/app.toml b/docker/rpcnode/config/app.toml index c8134e320a..d4a6556c20 100644 --- a/docker/rpcnode/config/app.toml +++ b/docker/rpcnode/config/app.toml @@ -266,6 +266,10 @@ enable_test_api = true # Set to 0 to disable request limiter, otherwise this limits the number of concurrent simulation calls. max_concurrent_simulation_calls = 0 +# Native debug tracers allowed in TraceConfig.Tracer. JavaScript tracer source is disabled. +trace_allowed_tracers = ["callTracer", "prestateTracer", "flatCallTracer", "4byteTracer", "noopTracer", "muxTracer"] +trace_allow_js_tracers = false + # Legacy sei_* / sei2_* JSON-RPC (EVM HTTP only). # DEPRECATION: All sei_* and sei2_* methods are deprecated and scheduled for removal - no new integrations. # HTTP 200; gate errors: JSON-RPC error (data legacy_sei_deprecated). Success: unchanged body; optional header Sei-Legacy-RPC-Deprecation. diff --git a/evmrpc/config/config.go b/evmrpc/config/config.go index e4a3d07f44..1b5c761f62 100644 --- a/evmrpc/config/config.go +++ b/evmrpc/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "runtime" + "strings" "time" "github.com/ethereum/go-ethereum/rpc" @@ -31,8 +32,43 @@ const ( // goroutine creation on high-core machines. Tasks are primarily I/O bound // (fetching and processing block logs), so 2x CPU cores can be excessive. MaxWorkerPoolSize = 64 + + TraceTracer4Byte = "4byteTracer" + TraceTracerCall = "callTracer" + TraceTracerFlatCall = "flatCallTracer" + TraceTracerMux = "muxTracer" + TraceTracerNoop = "noopTracer" + TraceTracerPrestate = "prestateTracer" ) +var nativeTraceTracers = map[string]struct{}{ + TraceTracer4Byte: {}, + TraceTracerCall: {}, + TraceTracerFlatCall: {}, + TraceTracerMux: {}, + TraceTracerNoop: {}, + TraceTracerPrestate: {}, +} + +// DefaultTraceAllowedTracers returns the native debug tracers allowed by default. +func DefaultTraceAllowedTracers() []string { + return []string{ + TraceTracerCall, + TraceTracerPrestate, + TraceTracerFlatCall, + TraceTracer4Byte, + TraceTracerNoop, + TraceTracerMux, + } +} + +// IsNativeTraceTracer reports whether name is a registered native geth tracer +// name that can be safely allowlisted without enabling request-supplied JS. +func IsNativeTraceTracer(name string) bool { + _, ok := nativeTraceTracers[name] + return ok +} + // EVMRPC Config defines configurations for EVM RPC server on this node type Config struct { // controls whether an HTTP EVM server is enabled @@ -141,6 +177,17 @@ type Config struct { // (matches upstream geth behavior). MaxTraceStructLogBytes uint64 `mapstructure:"max_trace_struct_log_bytes"` + // TraceAllowedTracers lists native debug tracer names that callers may + // request with TraceConfig.Tracer. The default struct logger remains + // available when Tracer is omitted. Request-supplied JavaScript tracers are + // never allowed through this list. + TraceAllowedTracers []string `mapstructure:"trace_allowed_tracers"` + + // TraceAllowJSTracers permits callers to supply JavaScript tracer source in + // TraceConfig.Tracer. This executes request-supplied code in-process and is + // disabled by default. + TraceAllowJSTracers bool `mapstructure:"trace_allow_js_tracers"` + // EnableParallelizedBlockTrace enables the parallelized default debug_traceBlock* path. EnableParallelizedBlockTrace bool `mapstructure:"enable_parallelized_block_trace"` @@ -238,6 +285,8 @@ var DefaultConfig = Config{ MaxTraceLookbackBlocks: 10000, TraceTimeout: 30 * time.Second, MaxTraceStructLogBytes: 32 * 1024 * 1024, // 32 MiB + TraceAllowedTracers: DefaultTraceAllowedTracers(), + TraceAllowJSTracers: false, EnableParallelizedBlockTrace: false, RPCStatsInterval: 10 * time.Second, WorkerPoolSize: min(MaxWorkerPoolSize, runtime.NumCPU()*2), // Default: min(64, CPU cores × 2) @@ -292,6 +341,8 @@ const ( flagMaxTraceLookbackBlocks = "evm.max_trace_lookback_blocks" flagTraceTimeout = "evm.trace_timeout" flagMaxTraceStructLogBytes = "evm.max_trace_struct_log_bytes" + flagTraceAllowedTracers = "evm.trace_allowed_tracers" + flagTraceAllowJSTracers = "evm.trace_allow_js_tracers" flagEnableParallelizedBlockTrace = "evm.enable_parallelized_block_trace" flagRPCStatsInterval = "evm.rpc_stats_interval" flagWorkerPoolSize = "evm.worker_pool_size" @@ -456,6 +507,19 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { return cfg, err } } + if v := opts.Get(flagTraceAllowedTracers); v != nil { + if cfg.TraceAllowedTracers, err = cast.ToStringSliceE(v); err != nil { + return cfg, err + } + } + if cfg.TraceAllowedTracers, err = normalizeTraceAllowedTracers(cfg.TraceAllowedTracers); err != nil { + return cfg, err + } + if v := opts.Get(flagTraceAllowJSTracers); v != nil { + if cfg.TraceAllowJSTracers, err = cast.ToBoolE(v); err != nil { + return cfg, err + } + } if v := opts.Get(flagEnableParallelizedBlockTrace); v != nil { if cfg.EnableParallelizedBlockTrace, err = cast.ToBoolE(v); err != nil { return cfg, err @@ -557,6 +621,26 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { return cfg, nil } +func normalizeTraceAllowedTracers(names []string) ([]string, error) { + out := make([]string, 0, len(names)) + seen := make(map[string]struct{}, len(names)) + for _, name := range names { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return nil, fmt.Errorf("%s entries must not be empty", flagTraceAllowedTracers) + } + if !IsNativeTraceTracer(trimmed) { + return nil, fmt.Errorf("%s contains non-native tracer %q", flagTraceAllowedTracers, trimmed) + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + return out, nil +} + // ConfigTemplate defines the TOML configuration template for EVM RPC const ConfigTemplate = ` ############################################################################### @@ -709,6 +793,16 @@ trace_timeout = "{{ .EVM.TraceTimeout }}" # upstream geth behavior). max_trace_struct_log_bytes = {{ .EVM.MaxTraceStructLogBytes }} +# Native debug tracers that may be requested with TraceConfig.Tracer. The default +# struct logger remains available when Tracer is omitted. Request-supplied +# JavaScript tracer source is disabled and cannot be enabled through this list. +# Set to [] to disable all named tracers. +trace_allowed_tracers = [{{- range $i, $t := .EVM.TraceAllowedTracers }}{{- if $i }}, {{ end }}"{{ $t }}"{{- end }}] + +# Allow request-supplied JavaScript tracer source in TraceConfig.Tracer. This +# executes untrusted code in-process; keep disabled on public/default RPC nodes. +trace_allow_js_tracers = {{ .EVM.TraceAllowJSTracers }} + # Enable the parallelized default debug_traceBlock* path. enable_parallelized_block_trace = {{ .EVM.EnableParallelizedBlockTrace }} diff --git a/evmrpc/config/config_test.go b/evmrpc/config/config_test.go index 6e41dd607d..cfc077ad9d 100644 --- a/evmrpc/config/config_test.go +++ b/evmrpc/config/config_test.go @@ -49,6 +49,8 @@ type opts struct { maxConcurrentRequestBytes interface{} maxOpenConnections interface{} maxTraceStructLogBytes interface{} + traceAllowedTracers interface{} + traceAllowJSTracers interface{} } func (o *opts) Get(k string) interface{} { @@ -184,6 +186,12 @@ func (o *opts) Get(k string) interface{} { if k == "evm.max_trace_struct_log_bytes" { return o.maxTraceStructLogBytes } + if k == "evm.trace_allowed_tracers" { + return o.traceAllowedTracers + } + if k == "evm.trace_allow_js_tracers" { + return o.traceAllowJSTracers + } panic("unknown key") } @@ -230,6 +238,8 @@ func getDefaultOpts() opts { int64(128 * 1024 * 1024), 2000, uint64(256 * 1024 * 1024), + []string{"callTracer", "prestateTracer"}, + false, } } @@ -240,6 +250,8 @@ func TestReadConfig(t *testing.T) { require.False(t, cfg.EnableParallelizedBlockTrace) // Round-trip: an explicitly-supplied value overrides the default. require.Equal(t, uint64(256*1024*1024), cfg.MaxTraceStructLogBytes) + require.Equal(t, []string{"callTracer", "prestateTracer"}, cfg.TraceAllowedTracers) + require.False(t, cfg.TraceAllowJSTracers) // The shipped default (used when the operator supplies no value). require.Equal(t, uint64(32*1024*1024), config.DefaultConfig.MaxTraceStructLogBytes) badOpts := goodOpts @@ -343,6 +355,21 @@ func TestReadConfig(t *testing.T) { _, err = config.ReadConfig(&badOpts) require.NotNil(t, err) + badOpts = goodOpts + badOpts.traceAllowedTracers = map[string]interface{}{} + _, err = config.ReadConfig(&badOpts) + require.NotNil(t, err) + + badOpts = goodOpts + badOpts.traceAllowedTracers = []string{"callTracer", "function() { return {}; }"} + _, err = config.ReadConfig(&badOpts) + require.NotNil(t, err) + + badOpts = goodOpts + badOpts.traceAllowJSTracers = "bad" + _, err = config.ReadConfig(&badOpts) + require.NotNil(t, err) + // Test bad types for worker pool config badOpts = goodOpts badOpts.workerPoolSize = "bad" @@ -502,6 +529,41 @@ func TestReadConfigEnableParallelizedBlockTrace(t *testing.T) { require.True(t, cfg.EnableParallelizedBlockTrace) } +func TestReadConfigTraceAllowedTracers(t *testing.T) { + cfg, err := config.ReadConfig(&opts{}) + require.NoError(t, err) + require.Equal(t, config.DefaultTraceAllowedTracers(), cfg.TraceAllowedTracers) + + opts := getDefaultOpts() + opts.traceAllowedTracers = []string{ + "callTracer", + "callTracer", + " flatCallTracer ", + } + cfg, err = config.ReadConfig(&opts) + require.NoError(t, err) + require.Equal(t, []string{"callTracer", "flatCallTracer"}, cfg.TraceAllowedTracers) + + opts.traceAllowedTracers = []string{"callTracer", ""} + _, err = config.ReadConfig(&opts) + require.Error(t, err) + + opts.traceAllowedTracers = []string{"callTracer", "badTracer"} + _, err = config.ReadConfig(&opts) + require.Error(t, err) + + opts.traceAllowedTracers = []string{"callTracer", "function() { return {}; }"} + opts.traceAllowJSTracers = true + _, err = config.ReadConfig(&opts) + require.Error(t, err, "trace_allowed_tracers remains native-only even when JS tracers are allowed") + + opts.traceAllowedTracers = []string{"callTracer"} + opts.traceAllowJSTracers = true + cfg, err = config.ReadConfig(&opts) + require.NoError(t, err) + require.True(t, cfg.TraceAllowJSTracers) +} + func TestReadConfigMaxSubscriptionsLogs(t *testing.T) { opts := getDefaultOpts() opts.maxSubscriptionsLogs = uint64(42) diff --git a/evmrpc/trace_profile.go b/evmrpc/trace_profile.go index f061808ecd..244836c5c6 100644 --- a/evmrpc/trace_profile.go +++ b/evmrpc/trace_profile.go @@ -48,6 +48,9 @@ func (api *DebugAPI) TraceTransactionProfile(ctx context.Context, hash common.Ha recordMetricsWithError(ctx, "debug_traceTransactionProfile", api.connectionType, startTime, returnErr, recover()) }() + if returnErr = api.validateTraceTracer(config); returnErr != nil { + return nil, returnErr + } if returnErr = api.guardHistoricalDebugTraceByTxHash(ctx, "debug_traceTransactionProfile", hash); returnErr != nil { return nil, returnErr } diff --git a/evmrpc/tracers.go b/evmrpc/tracers.go index 807ed72094..f7383d8b69 100644 --- a/evmrpc/tracers.go +++ b/evmrpc/tracers.go @@ -33,9 +33,10 @@ const ( IsPanicCacheSize = 5000 IsPanicCacheTTL = 1 * time.Minute - callTracerName = "callTracer" - prestateTracerName = "prestateTracer" - flatCallTracerName = "flatCallTracer" + callTracerName = evmrpcconfig.TraceTracerCall + prestateTracerName = evmrpcconfig.TraceTracerPrestate + flatCallTracerName = evmrpcconfig.TraceTracerFlatCall + muxTracerName = evmrpcconfig.TraceTracerMux ) var errTraceConcurrencyLimit = errors.New("trace request rejected due to concurrency limit: server busy") @@ -53,6 +54,8 @@ type DebugAPI struct { maxBlockLookback int64 traceTimeout time.Duration maxStructLogBytes int // per-call cap on retained default struct-logger output; 0 = unlimited + allowedTracers map[string]struct{} + allowJSTracers bool profiledBlockTrace bool } @@ -207,6 +210,8 @@ func NewDebugAPI( maxBlockLookback: debugCfg.MaxTraceLookbackBlocks, traceTimeout: debugCfg.TraceTimeout, maxStructLogBytes: clampUint64ToInt(debugCfg.MaxTraceStructLogBytes), + allowedTracers: buildAllowedTracerSet(debugCfg.TraceAllowedTracers), + allowJSTracers: debugCfg.TraceAllowJSTracers, profiledBlockTrace: debugCfg.EnableParallelizedBlockTrace, } } @@ -237,12 +242,78 @@ func (api *DebugAPI) clampDefaultStructLogLimit(config *tracers.TraceConfig) { } } +func buildAllowedTracerSet(names []string) map[string]struct{} { + allowed := make(map[string]struct{}, len(names)) + for _, name := range names { + allowed[name] = struct{}{} + } + return allowed +} + +func (api *DebugAPI) validateTraceTracer(config *tracers.TraceConfig) error { + if config == nil || config.Tracer == nil { + return nil + } + name := *config.Tracer + if name == "" { + return fmt.Errorf("debug tracer name must not be empty") + } + if _, ok := api.allowedTracers[name]; !ok { + if api.allowJSTracers && !evmrpcconfig.IsNativeTraceTracer(name) { + return nil + } + if api.allowJSTracers { + return fmt.Errorf("debug native tracer %q is not listed in evm.trace_allowed_tracers", name) + } + return fmt.Errorf("debug tracer %q is not allowed; JavaScript tracers are disabled and only native tracers listed in evm.trace_allowed_tracers may be used", name) + } + if name == muxTracerName { + if err := validateMuxTraceConfig(config.TracerConfig, api.allowedTracers, api.allowJSTracers); err != nil { + return fmt.Errorf("invalid muxTracer config: %w", err) + } + } + return nil +} + +func validateMuxTraceConfig(raw json.RawMessage, allowed map[string]struct{}, allowJS bool) error { + if len(raw) == 0 { + return nil + } + var nested map[string]json.RawMessage + if err := json.Unmarshal(raw, &nested); err != nil { + return err + } + for name, cfg := range nested { + if name == "" { + return fmt.Errorf("nested debug tracer name must not be empty") + } + if _, ok := allowed[name]; !ok { + if allowJS && !evmrpcconfig.IsNativeTraceTracer(name) { + continue + } + if allowJS { + return fmt.Errorf("nested native debug tracer %q is not listed in evm.trace_allowed_tracers", name) + } + return fmt.Errorf("nested debug tracer %q is not allowed; JavaScript tracers are disabled", name) + } + if name == muxTracerName { + if err := validateMuxTraceConfig(cfg, allowed, allowJS); err != nil { + return err + } + } + } + return nil +} + func (api *DebugAPI) TraceTransaction(ctx context.Context, hash common.Hash, config *tracers.TraceConfig) (result interface{}, returnErr error) { startTime := time.Now() defer func() { recordMetricsWithError(ctx, "debug_traceTransaction", api.connectionType, startTime, returnErr, recover()) }() + if returnErr = api.validateTraceTracer(config); returnErr != nil { + return nil, returnErr + } if returnErr = api.guardHistoricalDebugTraceByTxHash(ctx, "debug_traceTransaction", hash); returnErr != nil { return nil, returnErr } @@ -450,6 +521,9 @@ func (api *DebugAPI) TraceBlockByNumber(ctx context.Context, number rpc.BlockNum recordMetricsWithError(ctx, "debug_traceBlockByNumber", api.connectionType, startTime, returnErr, recover()) }() + if returnErr = api.validateTraceTracer(config); returnErr != nil { + return nil, returnErr + } if returnErr = api.guardHistoricalDebugTraceByNumber(ctx, "debug_traceBlockByNumber", number); returnErr != nil { return nil, returnErr } @@ -482,6 +556,10 @@ func (api *DebugAPI) TraceBlockByHash(ctx context.Context, hash common.Hash, con recordMetricsWithError(ctx, "debug_traceBlockByHash", api.connectionType, startTime, returnErr, recover()) }() + if returnErr = api.validateTraceTracer(config); returnErr != nil { + return nil, returnErr + } + ctx, done, err := api.prepareTraceContext(ctx) if err != nil { return nil, err @@ -514,6 +592,13 @@ func (api *DebugAPI) TraceCall(ctx context.Context, args export.TransactionArgs, recordMetricsWithError(ctx, "debug_traceCall", api.connectionType, startTime, returnErr, recover()) }() + if config == nil { + config = &tracers.TraceCallConfig{} + } + if returnErr = api.validateTraceTracer(&config.TraceConfig); returnErr != nil { + return nil, returnErr + } + ctx, done, err := api.prepareTraceContext(ctx) if err != nil { return nil, err @@ -524,9 +609,6 @@ func (api *DebugAPI) TraceCall(ctx context.Context, args export.TransactionArgs, return nil, returnErr } - if config == nil { - config = &tracers.TraceCallConfig{} - } api.clampDefaultStructLogLimit(&config.TraceConfig) result, returnErr = api.tracersAPI.TraceCall(ctx, args, blockNrOrHash, config) return diff --git a/evmrpc/tracers_allowlist_test.go b/evmrpc/tracers_allowlist_test.go new file mode 100644 index 0000000000..9e721e28cb --- /dev/null +++ b/evmrpc/tracers_allowlist_test.go @@ -0,0 +1,109 @@ +package evmrpc + +import ( + "encoding/json" + "testing" + + "github.com/ethereum/go-ethereum/eth/tracers" + "github.com/stretchr/testify/require" +) + +func TestValidateTraceTracerAllowlist(t *testing.T) { + api := &DebugAPI{ + allowedTracers: buildAllowedTracerSet([]string{ + callTracerName, + prestateTracerName, + muxTracerName, + }), + } + + require.NoError(t, api.validateTraceTracer(nil)) + require.NoError(t, api.validateTraceTracer(&tracers.TraceConfig{})) + + name := callTracerName + require.NoError(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name})) + + name = flatCallTracerName + require.ErrorContains(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name}), "is not allowed") + + name = "function() { return {}; }" + require.ErrorContains(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name}), "JavaScript tracers are disabled") + + name = "" + require.ErrorContains(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name}), "must not be empty") +} + +func TestValidateTraceTracerAllowsJSWhenConfigured(t *testing.T) { + api := &DebugAPI{ + allowedTracers: buildAllowedTracerSet([]string{ + callTracerName, + muxTracerName, + }), + allowJSTracers: true, + } + + name := "function() { return {}; }" + require.NoError(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name})) + + name = flatCallTracerName + require.ErrorContains(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name}), "native tracer") + + name = "" + require.ErrorContains(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name}), "must not be empty") +} + +func TestValidateMuxTraceConfig(t *testing.T) { + api := &DebugAPI{ + allowedTracers: buildAllowedTracerSet([]string{ + callTracerName, + prestateTracerName, + muxTracerName, + }), + } + + name := muxTracerName + require.NoError(t, api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: json.RawMessage(`{"callTracer":{},"prestateTracer":{}}`), + })) + require.NoError(t, api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: json.RawMessage(`{"muxTracer":{"callTracer":{}}}`), + })) + + err := api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: json.RawMessage(`{"function() { return {}; }":{}}`), + }) + require.ErrorContains(t, err, "nested debug tracer") + require.ErrorContains(t, err, "JavaScript tracers are disabled") + + err = api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: json.RawMessage(`{"muxTracer":{"badTracer":{}}}`), + }) + require.ErrorContains(t, err, "nested debug tracer") + require.ErrorContains(t, err, "badTracer") +} + +func TestValidateMuxTraceConfigAllowsJSWhenConfigured(t *testing.T) { + api := &DebugAPI{ + allowedTracers: buildAllowedTracerSet([]string{ + callTracerName, + muxTracerName, + }), + allowJSTracers: true, + } + + name := muxTracerName + require.NoError(t, api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: json.RawMessage(`{"function() { return {}; }":{}}`), + })) + + err := api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: json.RawMessage(`{"flatCallTracer":{}}`), + }) + require.ErrorContains(t, err, "nested native debug tracer") +} From 0f2b11f97eae888ec3b50162a01cb5ee1b40a910 Mon Sep 17 00:00:00 2001 From: "Masih H. Derkani" Date: Wed, 8 Jul 2026 14:37:31 +0100 Subject: [PATCH 2/3] Address review comment --- evmrpc/tracers.go | 11 ++++++++++- evmrpc/tracers_allowlist_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/evmrpc/tracers.go b/evmrpc/tracers.go index f7383d8b69..518120495c 100644 --- a/evmrpc/tracers.go +++ b/evmrpc/tracers.go @@ -37,6 +37,8 @@ const ( prestateTracerName = evmrpcconfig.TraceTracerPrestate flatCallTracerName = evmrpcconfig.TraceTracerFlatCall muxTracerName = evmrpcconfig.TraceTracerMux + + maxMuxTracerNestingDepth = 16 ) var errTraceConcurrencyLimit = errors.New("trace request rejected due to concurrency limit: server busy") @@ -276,9 +278,16 @@ func (api *DebugAPI) validateTraceTracer(config *tracers.TraceConfig) error { } func validateMuxTraceConfig(raw json.RawMessage, allowed map[string]struct{}, allowJS bool) error { + return validateMuxTraceConfigDepth(raw, allowed, allowJS, 1) +} + +func validateMuxTraceConfigDepth(raw json.RawMessage, allowed map[string]struct{}, allowJS bool, depth int) error { if len(raw) == 0 { return nil } + if depth > maxMuxTracerNestingDepth { + return fmt.Errorf("muxTracer nesting depth exceeds maximum of %d", maxMuxTracerNestingDepth) + } var nested map[string]json.RawMessage if err := json.Unmarshal(raw, &nested); err != nil { return err @@ -297,7 +306,7 @@ func validateMuxTraceConfig(raw json.RawMessage, allowed map[string]struct{}, al return fmt.Errorf("nested debug tracer %q is not allowed; JavaScript tracers are disabled", name) } if name == muxTracerName { - if err := validateMuxTraceConfig(cfg, allowed, allowJS); err != nil { + if err := validateMuxTraceConfigDepth(cfg, allowed, allowJS, depth+1); err != nil { return err } } diff --git a/evmrpc/tracers_allowlist_test.go b/evmrpc/tracers_allowlist_test.go index 9e721e28cb..38e6764d8e 100644 --- a/evmrpc/tracers_allowlist_test.go +++ b/evmrpc/tracers_allowlist_test.go @@ -2,6 +2,7 @@ package evmrpc import ( "encoding/json" + "strings" "testing" "github.com/ethereum/go-ethereum/eth/tracers" @@ -86,6 +87,27 @@ func TestValidateMuxTraceConfig(t *testing.T) { require.ErrorContains(t, err, "badTracer") } +func TestValidateMuxTraceConfigRejectsDeepNesting(t *testing.T) { + api := &DebugAPI{ + allowedTracers: buildAllowedTracerSet([]string{ + callTracerName, + muxTracerName, + }), + } + + name := muxTracerName + require.NoError(t, api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: nestedMuxTraceConfig(maxMuxTracerNestingDepth - 1), + })) + + err := api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: nestedMuxTraceConfig(maxMuxTracerNestingDepth), + }) + require.ErrorContains(t, err, "muxTracer nesting depth exceeds maximum") +} + func TestValidateMuxTraceConfigAllowsJSWhenConfigured(t *testing.T) { api := &DebugAPI{ allowedTracers: buildAllowedTracerSet([]string{ @@ -107,3 +129,13 @@ func TestValidateMuxTraceConfigAllowsJSWhenConfigured(t *testing.T) { }) require.ErrorContains(t, err, "nested native debug tracer") } + +func nestedMuxTraceConfig(nestedMuxTracers int) json.RawMessage { + var builder strings.Builder + for range nestedMuxTracers { + builder.WriteString(`{"muxTracer":`) + } + builder.WriteString(`{"callTracer":{}}`) + builder.WriteString(strings.Repeat("}", nestedMuxTracers)) + return json.RawMessage(builder.String()) +} From 4011e432050a75ede9fdc14f2dea099604390c9a Mon Sep 17 00:00:00 2001 From: "Masih H. Derkani" Date: Wed, 8 Jul 2026 15:21:43 +0100 Subject: [PATCH 3/3] Harden validation and update agent files while at it --- evmrpc/AGENTS.md | 2 ++ evmrpc/config/config.go | 18 +++++++++++++----- evmrpc/config/config_test.go | 28 +++++++++++++++++++++++++++- evmrpc/tracers.go | 30 +++++++++++++++++++----------- evmrpc/tracers_allowlist_test.go | 25 +++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 17 deletions(-) diff --git a/evmrpc/AGENTS.md b/evmrpc/AGENTS.md index 666a655992..315eef3f5b 100644 --- a/evmrpc/AGENTS.md +++ b/evmrpc/AGENTS.md @@ -23,5 +23,7 @@ Legacy **`sei_*` and `sei2_*`** JSON-RPC (EVM HTTP only) are **gated** by the sa ## `debug_` prefixed endpoints `debug_trace*` endpoints should faithfully replay historical execution. If a transaction encountered an error during its actual execution, a `debug_trace*` call for it should reflect so. If a transction consumed X amount of gas during its actual execution, a `debug_trace*` call should show that exact amount as well. +**Tracer gating (deviation from geth defaults):** caller-supplied `TraceConfig.Tracer` values on `debug_traceCall` / `debug_traceTransaction` / `debug_traceBlockBy*` / `debug_traceTransactionProfile` are gated by `[evm]` config in `app.toml`. `trace_allowed_tracers` lists the native geth tracer names callers may request (validated native-only at startup; `muxTracer` nested tracer names are validated recursively with a bounded depth). `trace_allow_js_tracers` (default `false`) is a separate explicit opt-in for request-supplied JavaScript tracer source — upstream geth accepts JS tracers by default, Sei does not. Enabling JS does **not** widen the native allowlist. Validation runs in `validateTraceTracer` (`tracers.go`) before trace-cache lookups and before any tracer is constructed; the default struct logger (no `tracer` field) is always available. `trace_bake_tracers` is held to the same native-only rule at startup. + ## Consistency RPC responses for historical heights should never change as the blockchain progresses, or as the blockchain code gets upgraded. diff --git a/evmrpc/config/config.go b/evmrpc/config/config.go index 1b5c761f62..c0960a341d 100644 --- a/evmrpc/config/config.go +++ b/evmrpc/config/config.go @@ -512,7 +512,7 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { return cfg, err } } - if cfg.TraceAllowedTracers, err = normalizeTraceAllowedTracers(cfg.TraceAllowedTracers); err != nil { + if cfg.TraceAllowedTracers, err = normalizeNativeTracerNames(flagTraceAllowedTracers, cfg.TraceAllowedTracers); err != nil { return cfg, err } if v := opts.Get(flagTraceAllowJSTracers); v != nil { @@ -565,6 +565,12 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { return cfg, err } } + // The baker feeds these names straight into geth tracing, so hold them to + // the same native-only rule as request-supplied tracers: a typo'd name must + // fail at startup instead of being evaluated as JS source on every block. + if cfg.TraceBakeTracers, err = normalizeNativeTracerNames(flagTraceBakeTracers, cfg.TraceBakeTracers); err != nil { + return cfg, err + } if v := opts.Get(flagTraceBakeWindowBlocks); v != nil { if cfg.TraceBakeWindowBlocks, err = cast.ToInt64E(v); err != nil { return cfg, err @@ -621,16 +627,16 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { return cfg, nil } -func normalizeTraceAllowedTracers(names []string) ([]string, error) { +func normalizeNativeTracerNames(flagName string, names []string) ([]string, error) { out := make([]string, 0, len(names)) seen := make(map[string]struct{}, len(names)) for _, name := range names { trimmed := strings.TrimSpace(name) if trimmed == "" { - return nil, fmt.Errorf("%s entries must not be empty", flagTraceAllowedTracers) + return nil, fmt.Errorf("%s entries must not be empty", flagName) } if !IsNativeTraceTracer(trimmed) { - return nil, fmt.Errorf("%s contains non-native tracer %q", flagTraceAllowedTracers, trimmed) + return nil, fmt.Errorf("%s contains non-native tracer %q", flagName, trimmed) } if _, ok := seen[trimmed]; ok { continue @@ -801,6 +807,8 @@ trace_allowed_tracers = [{{- range $i, $t := .EVM.TraceAllowedTracers }}{{- if $ # Allow request-supplied JavaScript tracer source in TraceConfig.Tracer. This # executes untrusted code in-process; keep disabled on public/default RPC nodes. +# Enabling this does not widen trace_allowed_tracers: native tracer names must +# still be listed there to be usable. trace_allow_js_tracers = {{ .EVM.TraceAllowJSTracers }} # Enable the parallelized default debug_traceBlock* path. @@ -828,7 +836,7 @@ trace_bake_workers = {{ .EVM.TraceBakeWorkers }} # Bounded in-flight height queue. Drops on full so consensus never blocks. trace_bake_queue_size = {{ .EVM.TraceBakeQueueSize }} -# Which tracers to bake per block; only standard named tracers are eligible. +# Which tracers to bake per block; must be native tracer names (validated at startup). trace_bake_tracers = [{{- range $i, $t := .EVM.TraceBakeTracers }}{{- if $i }}, {{ end }}"{{ $t }}"{{- end }}] # Rolling cache window: prune blocks older than (latest - this). diff --git a/evmrpc/config/config_test.go b/evmrpc/config/config_test.go index cfc077ad9d..9da73d5ebe 100644 --- a/evmrpc/config/config_test.go +++ b/evmrpc/config/config_test.go @@ -51,6 +51,7 @@ type opts struct { maxTraceStructLogBytes interface{} traceAllowedTracers interface{} traceAllowJSTracers interface{} + traceBakeTracers interface{} } func (o *opts) Get(k string) interface{} { @@ -153,10 +154,12 @@ func (o *opts) Get(k string) interface{} { if k == "evm.enabled_legacy_sei_apis" { return nil } + if k == "evm.trace_bake_tracers" { + return o.traceBakeTracers + } if k == "evm.trace_bake_enabled" || k == "evm.trace_bake_workers" || k == "evm.trace_bake_queue_size" || - k == "evm.trace_bake_tracers" || k == "evm.trace_bake_window_blocks" || k == "evm.trace_bake_use_snapshot" || k == "evm.trace_bake_snapshot_window" { @@ -240,6 +243,7 @@ func getDefaultOpts() opts { uint64(256 * 1024 * 1024), []string{"callTracer", "prestateTracer"}, false, + nil, } } @@ -564,6 +568,28 @@ func TestReadConfigTraceAllowedTracers(t *testing.T) { require.True(t, cfg.TraceAllowJSTracers) } +func TestReadConfigTraceBakeTracers(t *testing.T) { + cfg, err := config.ReadConfig(&opts{}) + require.NoError(t, err) + require.Equal(t, []string{"callTracer"}, cfg.TraceBakeTracers) + + o := getDefaultOpts() + o.traceBakeTracers = []string{"prestateTracer", " callTracer ", "prestateTracer"} + cfg, err = config.ReadConfig(&o) + require.NoError(t, err) + require.Equal(t, []string{"prestateTracer", "callTracer"}, cfg.TraceBakeTracers) + + o.traceBakeTracers = []string{"callTracer", "badTracer"} + _, err = config.ReadConfig(&o) + require.Error(t, err) + + // A JS-looking name must fail at startup rather than being evaluated as + // JS source by the baker on every committed block. + o.traceBakeTracers = []string{"function() { return {}; }"} + _, err = config.ReadConfig(&o) + require.Error(t, err) +} + func TestReadConfigMaxSubscriptionsLogs(t *testing.T) { opts := getDefaultOpts() opts.maxSubscriptionsLogs = uint64(42) diff --git a/evmrpc/tracers.go b/evmrpc/tracers.go index 518120495c..21054a7a85 100644 --- a/evmrpc/tracers.go +++ b/evmrpc/tracers.go @@ -7,6 +7,7 @@ import ( "fmt" "math" "runtime/debug" + "strings" "sync" "time" @@ -256,10 +257,14 @@ func (api *DebugAPI) validateTraceTracer(config *tracers.TraceConfig) error { if config == nil || config.Tracer == nil { return nil } - name := *config.Tracer + name := strings.TrimSpace(*config.Tracer) if name == "" { - return fmt.Errorf("debug tracer name must not be empty") + return errors.New("debug tracer name must not be empty") } + // Write the trimmed name back so geth resolves the exact tracer that was + // validated; otherwise a padded native name (e.g. " callTracer") would pass + // validation here yet be treated as JS source by the tracer directory. + *config.Tracer = name if _, ok := api.allowedTracers[name]; !ok { if api.allowJSTracers && !evmrpcconfig.IsNativeTraceTracer(name) { return nil @@ -270,18 +275,14 @@ func (api *DebugAPI) validateTraceTracer(config *tracers.TraceConfig) error { return fmt.Errorf("debug tracer %q is not allowed; JavaScript tracers are disabled and only native tracers listed in evm.trace_allowed_tracers may be used", name) } if name == muxTracerName { - if err := validateMuxTraceConfig(config.TracerConfig, api.allowedTracers, api.allowJSTracers); err != nil { + if err := validateMuxTraceConfig(config.TracerConfig, api.allowedTracers, api.allowJSTracers, 1); err != nil { return fmt.Errorf("invalid muxTracer config: %w", err) } } return nil } -func validateMuxTraceConfig(raw json.RawMessage, allowed map[string]struct{}, allowJS bool) error { - return validateMuxTraceConfigDepth(raw, allowed, allowJS, 1) -} - -func validateMuxTraceConfigDepth(raw json.RawMessage, allowed map[string]struct{}, allowJS bool, depth int) error { +func validateMuxTraceConfig(raw json.RawMessage, allowed map[string]struct{}, allowJS bool, depth int) error { if len(raw) == 0 { return nil } @@ -293,8 +294,15 @@ func validateMuxTraceConfigDepth(raw json.RawMessage, allowed map[string]struct{ return err } for name, cfg := range nested { - if name == "" { - return fmt.Errorf("nested debug tracer name must not be empty") + if strings.TrimSpace(name) == "" { + return errors.New("nested debug tracer name must not be empty") + } + // Nested names live inside caller-supplied JSON that is forwarded to + // geth verbatim, so a padded name cannot be rewritten the way the + // top-level tracer is. Reject it instead of validating a name geth + // would resolve differently. + if name != strings.TrimSpace(name) { + return fmt.Errorf("nested debug tracer name %q must not have leading or trailing whitespace", name) } if _, ok := allowed[name]; !ok { if allowJS && !evmrpcconfig.IsNativeTraceTracer(name) { @@ -306,7 +314,7 @@ func validateMuxTraceConfigDepth(raw json.RawMessage, allowed map[string]struct{ return fmt.Errorf("nested debug tracer %q is not allowed; JavaScript tracers are disabled", name) } if name == muxTracerName { - if err := validateMuxTraceConfigDepth(cfg, allowed, allowJS, depth+1); err != nil { + if err := validateMuxTraceConfig(cfg, allowed, allowJS, depth+1); err != nil { return err } } diff --git a/evmrpc/tracers_allowlist_test.go b/evmrpc/tracers_allowlist_test.go index 38e6764d8e..f74b373791 100644 --- a/evmrpc/tracers_allowlist_test.go +++ b/evmrpc/tracers_allowlist_test.go @@ -6,9 +6,21 @@ import ( "testing" "github.com/ethereum/go-ethereum/eth/tracers" + evmrpcconfig "github.com/sei-protocol/sei-chain/evmrpc/config" "github.com/stretchr/testify/require" ) +// TestDefaultAllowedTracersAreNativeInGethRegistry pins the hardcoded native +// tracer list to the actual go-ethereum registry (whose init()s this package +// imports): every default-allowlisted name must be registered and non-JS, so a +// geth upgrade that changes the registry fails CI instead of silently +// weakening the JS-tracer gate. +func TestDefaultAllowedTracersAreNativeInGethRegistry(t *testing.T) { + for _, name := range evmrpcconfig.DefaultTraceAllowedTracers() { + require.False(t, tracers.DefaultDirectory.IsJS(name), "tracer %q must be registered as a native (non-JS) tracer in go-ethereum", name) + } +} + func TestValidateTraceTracerAllowlist(t *testing.T) { api := &DebugAPI{ allowedTracers: buildAllowedTracerSet([]string{ @@ -24,6 +36,11 @@ func TestValidateTraceTracerAllowlist(t *testing.T) { name := callTracerName require.NoError(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name})) + name = " callTracer " + paddedCfg := &tracers.TraceConfig{Tracer: &name} + require.NoError(t, api.validateTraceTracer(paddedCfg)) + require.Equal(t, callTracerName, *paddedCfg.Tracer, "validated name must be written back trimmed so geth resolves the native tracer") + name = flatCallTracerName require.ErrorContains(t, api.validateTraceTracer(&tracers.TraceConfig{Tracer: &name}), "is not allowed") @@ -85,6 +102,14 @@ func TestValidateMuxTraceConfig(t *testing.T) { }) require.ErrorContains(t, err, "nested debug tracer") require.ErrorContains(t, err, "badTracer") + + // Nested names are forwarded to geth inside the raw JSON, so a padded + // native name cannot be rewritten and must be rejected outright. + err = api.validateTraceTracer(&tracers.TraceConfig{ + Tracer: &name, + TracerConfig: json.RawMessage(`{" callTracer":{}}`), + }) + require.ErrorContains(t, err, "must not have leading or trailing whitespace") } func TestValidateMuxTraceConfigRejectsDeepNesting(t *testing.T) {