eth/filters, rpc: add --rpc.rangelimit flag and fix filter config pass-through#2147
Conversation
|
@claude review |
|
Claude finished @adamdossa's task in 1m 27s —— View job Code Review:
|
| # | Severity | Issue | Actionable? |
|---|---|---|---|
| 1 | Medium | Missing begin > end check in GetBorBlockLogs |
Yes — add the guard |
| 2 | Low | PR description mentions eth_newFilter but limit isn't enforced there |
Clarify description |
| 3 | Low | Missing HistoryPruningCutoff in GetBorBlockLogs (pre-existing) |
Follow-up |
The backend.go fix alone (passing real config instead of filters.Config{}) is valuable and should prevent future config fields from being silently dropped.
|
Could you run make docs as well? |
|
@kamuikatsurgi , I did but it was updating some flags and removing others. I think if i reflect these changes here, when bringing to develop it will drop flags that exists today |
|
Codecov Report❌ Patch coverage is
❌ Your patch status has failed because the patch coverage (86.95%) is below the target coverage (90.00%). You can increase the patch coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## v2.6.4-candidate #2147 +/- ##
===================================================
Coverage ? 50.05%
===================================================
Files ? 878
Lines ? 151740
Branches ? 0
===================================================
Hits ? 75959
Misses ? 70692
Partials ? 5089
🚀 New features to boost your workflow:
|
| // - all other sentinels (latest, safe, finalized, pending) → head | ||
| // - concrete non-negative values → unchanged | ||
| func resolveBlockNumForRangeCheck(n int64, head uint64) uint64 { | ||
| if n >= 0 { | ||
| return uint64(n) | ||
| } | ||
| if n == rpc.EarliestBlockNumber.Int64() { | ||
| return 0 | ||
| } | ||
| return head // latest, safe, finalized, pending all treated as head | ||
| } |
There was a problem hiding this comment.
🔴 resolveBlockNumForRangeCheck maps finalized (-3) and safe (-4) to head, but the actual filter in resolveSpecial (filter.go:112-123) resolves them to their real block heights via HeaderByNumber. This allows complete bypass of --rpc.rangelimit: e.g., with head=10000, finalized=9500, limit=100, a query fromBlock=finalized, toBlock=latest passes the check (range=0) but scans 500 blocks. Additionally, earliest (-5) is mapped to 0 instead of HistoryPruningCutoff(), causing false rejections on pruned nodes.
Extended reasoning...
The Bug
The newly introduced resolveBlockNumForRangeCheck function (api.go:65-75) is responsible for converting symbolic block numbers into concrete heights for the range limit guard. However, it does not match the actual resolution behavior in filter.go:resolveSpecial (lines 104-137), leading to two distinct issues:
1. Range limit bypass via finalized/safe sentinels
resolveBlockNumForRangeCheck maps FinalizedBlockNumber (-3) and SafeBlockNumber (-4) to head (line 74), treating them identically to LatestBlockNumber. However, the actual filter execution in resolveSpecial resolves these to their real block heights by calling HeaderByNumber(ctx, rpc.FinalizedBlockNumber) (line 113) and HeaderByNumber(ctx, rpc.SafeBlockNumber) (line 119), which can return values significantly behind head.
Step-by-step proof:
- Node has head at block 10000, finalized at block 9500,
--rpc.rangelimitset to 100. - User calls
eth_getLogswithfromBlock: "finalized",toBlock: "latest". checkBlockRangeLimitcallsresolveBlockNumForRangeCheck(-3, 10000)which returns10000(head), andresolveBlockNumForRangeCheck(-2, 10000)which also returns10000.- The computed range = 10000 - 10000 = 0, which is <= 100, so the check passes.
- The actual filter in
resolveSpecialresolves finalized to 9500 (viaHeaderByNumber) and latest toMaxUint64(which maps to head=10000 during iteration). - The filter scans blocks 9500-10000 = 500 blocks, which is 5x the configured 100-block limit.
On Polygon/Bor specifically, the finalized block can lag significantly behind head due to Heimdall milestone/checkpoint delays. This completely defeats the purpose of --rpc.rangelimit, which exists to protect RPC nodes from expensive wide-range log queries.
2. False rejection of earliest-based queries on pruned nodes (minor)
resolveBlockNumForRangeCheck maps EarliestBlockNumber (-5) to 0 (line 71-72). However, resolveSpecial maps it to HistoryPruningCutoff() (line 125), which on pruned nodes can be much higher than 0.
Step-by-step proof:
- Node is pruned with
HistoryPruningCutoff = 74995000, head = 75000000,--rpc.rangelimit= 10000. - User calls
eth_getLogswithfromBlock: "earliest",toBlock: "latest". - Range check resolves earliest to 0, latest to 75000000. Range = 75000000 > 10000 → REJECTED.
- Note: The
HistoryPruningCutoffguard at api.go line 523 is skipped becausebegin = -5fails thebegin >= 0check. - But the actual filter resolves earliest to 74995000 and latest to head (75000000), scanning only 5000 blocks — well within the 10000 limit.
- A valid query is wrongly rejected.
This second issue is less severe (false rejection is conservative/safe, and users can work around it with concrete block numbers), but both stem from the same root cause.
Fix
resolveBlockNumForRangeCheck should resolve finalized/safe to their actual block heights via HeaderByNumber (or accept pre-resolved values), and resolve earliest to HistoryPruningCutoff() instead of 0. This requires the function to have access to the backend, so the signature would need to change.
| filterSystem := filters.NewFilterSystem(backend, filters.Config{ | ||
| LogCacheSize: ethcfg.FilterLogCacheSize, | ||
| LogQueryLimit: ethcfg.LogQueryLimit, | ||
| RangeLimit: ethcfg.RPCBlockRangeLimit, | ||
| }) | ||
|
|
||
| filterAPI := filters.NewFilterAPI(filterSystem, ethcfg.BorLogs) |
There was a problem hiding this comment.
🟣 Pre-existing bug: RegisterFilterAPI creates two separate NewFilterAPI instances — one stored in filterAPI (line 2247) and a different one registered as the RPC service (line 2250). SetChainConfig is called on the local filterAPI variable which is then discarded, while the registered instance retains chainConfig=nil. This causes bor_getLogs calls through the cmd/geth path to always fail with "no chain config found". The fix is a one-liner: use filterAPI at line 2250 instead of creating a new instance.
Extended reasoning...
What the bug is
In cmd/utils/flags.go:RegisterFilterAPI (lines 2241-2254), two separate filters.NewFilterAPI instances are created:
- Line 2247:
filterAPI := filters.NewFilterAPI(filterSystem, ethcfg.BorLogs)— stored in a local variable - Line 2250:
Service: filters.NewFilterAPI(filterSystem, ethcfg.BorLogs)— a completely different instance, registered withstack.RegisterAPIs
Then on line 2254, filterAPI.SetChainConfig(ethcfg.Genesis.Config) is called on instance #1 (the local variable). Instance #2 (the one actually serving RPC requests) never receives SetChainConfig, so its chainConfig field remains nil.
How it manifests
In eth/filters/bor_api.go:GetBorBlockLogs (line 19), the very first check is:
if api.chainConfig == nil {
return nil, errors.New("no chain config found. Proper PublicFilterAPI initialization required")
}Since the registered instance has chainConfig == nil, every bor_getLogs RPC call through the cmd/geth path will immediately return this error. The bor_getLogs endpoint is effectively broken for nodes started via the cmd/geth entry point.
Step-by-step proof
- User starts a node using
cmd/geth(which callsRegisterFilterAPI) RegisterFilterAPIcreatesfilterAPI(instance MAT-156 Span sync #1) at line 2247- At line 2250, a new
filters.NewFilterAPI(filterSystem, ethcfg.BorLogs)call creates instance build(deps): bump mixin-deep from 1.3.1 to 1.3.2 in /dashboard/assets #2 and registers it as theethnamespace RPC service filterAPI.SetChainConfig(ethcfg.Genesis.Config)at line 2254 sets chain config on instance MAT-156 Span sync #1- Instance MAT-156 Span sync #1 (
filterAPI) is never used again — it is not returned, stored, or referenced - A
bor_getLogsRPC request arrives and is routed to instance build(deps): bump mixin-deep from 1.3.1 to 1.3.2 in /dashboard/assets #2 - Instance build(deps): bump mixin-deep from 1.3.1 to 1.3.2 in /dashboard/assets #2 checks
api.chainConfig == nil→true→ returns error
Why existing code does not prevent it
The eth/backend.go path (used by the internal/cli/server entry point) does this correctly — it creates a single publicFilterAPI instance, calls SetChainConfig on it, and registers that same instance. However, the cmd/utils/flags.go path has this copy-paste-style duplication that was never caught.
Impact
Any Bor node started via the cmd/geth binary that receives a bor_getLogs RPC call will get an error response. The eth_getLogs path is unaffected since it does not check chainConfig. The internal/cli/server path (the primary Bor CLI) also correctly wires a single instance in eth/backend.go, so this bug primarily affects the legacy cmd/geth entry point.
How to fix
Replace the filters.NewFilterAPI(filterSystem, ethcfg.BorLogs) on line 2250 with the existing filterAPI variable:
stack.RegisterAPIs([]rpc.API{{
Namespace: "eth",
Service: filterAPI,
}})This ensures the same instance that receives SetChainConfig is the one serving RPC requests, matching the pattern already used in eth/backend.go.
| if crit.ToBlock != nil { | ||
| end = crit.ToBlock.Int64() | ||
| } | ||
| if begin > 0 && end > 0 && begin > end { | ||
| return nil, errInvalidBlockRange | ||
| } | ||
| head := api.sys.backend.CurrentHeader().Number.Uint64() | ||
| if err := checkBlockRangeLimit(begin, end, head, api.sys.cfg.RangeLimit); err != nil { | ||
| return nil, err | ||
| } | ||
| // Construct the range filter | ||
| filter = NewBorBlockLogsRangeFilter(api.sys.backend, borConfig, begin, end, crit.Addresses, crit.Topics) | ||
| } |
There was a problem hiding this comment.
🟣 Pre-existing: GetBorBlockLogs is missing maxTopics (>4), logQueryLimit (addresses/topics per position), and HistoryPruningCutoff validations that GetLogs has. The PR adds begin>end and range limit checks to this function but omits these other guards, meaning bor_getLogs accepts queries that eth_getLogs would reject. For HistoryPruningCutoff, querying pruned ranges silently returns empty results instead of a clear PrunedHistoryError.
Extended reasoning...
What is missing
GetLogs in api.go (lines 476-490) performs three validations before constructing a filter:
len(crit.Topics) > maxTopics— rejects queries with more than 4 topic positionslogQueryLimitcheck — rejects queries wherelen(crit.Addresses)or anylen(topics)per position exceeds the configured limitHistoryPruningCutoffcheck (line 523) — returnsPrunedHistoryErrorifbeginis before the pruning cutoff
GetBorBlockLogs in bor_api.go has none of these. The PR adds begin > end validation and checkBlockRangeLimit (lines 42-48) but does not add the other three checks.
Step-by-step proof
Consider a node with --rpc.logquerylimit 1000 and --bor.logs enabled:
- A caller sends
eth_getLogswith 2000 addresses → rejected atapi.go:482witherrExceedLogQueryLimit. - The same caller sends
bor_getLogswith 2000 addresses → passes all validation inbor_api.go, reachesNewBorBlockLogsRangeFilter, and the filter iterates every block in the range performing in-memory log matching against all 2000 addresses.
For HistoryPruningCutoff: if a node has pruned history below block 1,000,000 and a caller queries bor_getLogs with fromBlock=0, GetLogs would return PrunedHistoryError at line 523, but GetBorBlockLogs proceeds. BorBlockLogsFilter.unindexedLogs calls HeaderByNumber which returns nil for pruned blocks, causing the filter to silently return empty/partial results rather than a clear error.
Practical impact
The practical impact is low for several reasons:
- maxTopics: EVM only supports LOG0-LOG4, so >4 topic criteria would match nothing. Still a consistency issue.
- logQueryLimit:
BorBlockLogsFilteruses simple linear block iteration (not the filtermaps index), so the cost scales linearly rather than triggering expensive index queries. The block range limit (now enforced) is the primary DoS vector. - HistoryPruningCutoff: The filter handles pruned blocks gracefully (no crash or data corruption), but users get silently empty results instead of an informative error.
- bor_getLogs scope: This endpoint only covers pre-Madhugiri (PIP-74) blocks (
bor_filter.go:105), further limiting the attack surface.
Why this is pre-existing
These checks were never present in GetBorBlockLogs. The PR does not introduce the gap — it adds range limit and begin > end checks while missing the opportunity to also add maxTopics, logQueryLimit, and HistoryPruningCutoff for consistency.
Suggested fix
Add the following at the top of GetBorBlockLogs, mirroring GetLogs:
if len(crit.Topics) > maxTopics {
return nil, errExceedMaxTopics
}
if api.logQueryLimit != 0 {
if len(crit.Addresses) > api.logQueryLimit {
return nil, errExceedLogQueryLimit
}
for _, topics := range crit.Topics {
if len(topics) > api.logQueryLimit {
return nil, errExceedLogQueryLimit
}
}
}And in the range branch, before constructing the filter:
if begin >= 0 && begin < int64(api.events.backend.HistoryPruningCutoff()) {
return nil, &history.PrunedHistoryError{}
}…ass-through (#2147) * Include range limit filter flag * update docs * apply more range checks
…ass-through (#2147) * Include range limit filter flag * update docs * apply more range checks



Description
Summary
This PR cherry-picks two upstream changes and adapts them to Bor:
--rpc.rangelimitCLI flag that caps the maximum block range (end - begin) accepted byeth_getLogsandeth_newFilter, protecting RPC nodes from expensive wide-range queries.eth/backend.gowherefilters.NewFilterSystemwas called with an emptyfilters.Config{}, silently discardingLogCacheSize,LogQueryLimit, and (now)RangeLimitvalues for thebor_getLogsAPI path.Bor-specific adaptation
Bor exposes two log query endpoints:
eth_getLogs(FilterAPI.GetLogs) andbor_getLogs(FilterAPI.GetBorBlockLogs). The range limit is enforced on both.The flag is wired through Bor's primary flag pipeline (
internal/cli/server/flags.go→JsonRPCConfig→ethconfig.Config→filters.Config) in addition to the legacycmd/utils/flags.gopath forcmd/gethcompatibility.Changes
eth/filters/filter_system.goRangeLimit uint64tofilters.Config(0= unlimited).eth/ethconfig/config.goRPCBlockRangeLimit uint64toethconfig.Config(default0— operators opt-in).internal/cli/server/config.goRangeLimit uint64field toJsonRPCConfig(HCL/TOML key:rangelimit).JsonRPC.RangeLimit→ethconfig.RPCBlockRangeLimitduring config build.internal/cli/server/flags.go(primary flag location)--rpc.rangelimitUint64Flagin theJsonRPCgroup.cmd/utils/flags.goRPCGlobalRangeLimitFlag(--rpc.rangelimit) alongsideRPCGlobalLogQueryLimit.setEthConfigviactx.IsSetguard.RegisterFilterAPIto passRangeLimittofilters.NewFilterSystem.cmd/geth/main.goRPCGlobalRangeLimitFlagin the node flag list.eth/backend.go(fix from bor#2146)filters.Config{}inAPIs()with the full config, passingFilterLogCacheSize,RPCLogQueryLimit, andRPCBlockRangeLimit.eth/filters/api.goerrExceedBlockRangeLimiterror sentinel.GetLogsafterbegin/endare resolved, before filter construction.eth/filters/bor_api.goGetBorBlockLogs.eth/filters/filter_system_test.goTestRangeLimitcovering:errExceedBlockRangeLimitfor bothGetLogsandGetBorBlockLogs.RangeLimit = 0(unlimited) → large ranges are never rejected.Usage
The flag is also configurable via the HCL/TOML config file:
Changes
Breaking changes
Please complete this section if any breaking changes have been made, otherwise delete it
Nodes audience
In case this PR includes changes that must be applied only to a subset of nodes, please specify how you handled it (e.g. by adding a flag with a default value...)
Checklist
Cross repository changes
Testing
Manual tests
Please complete this section with the steps you performed if you ran manual tests for this functionality, otherwise delete it
Additional comments
Please post additional comments in this section if you have them, otherwise delete it