fix(sei-cosmos): harden paginated RPC queries against DoS via limit, offset, and count_total caps (PLT-361)#3494
Conversation
PR SummaryHigh Risk Overview
Call sites: Tests expect Reviewed by Cursor Bugbot for commit 55b905b. Bugbot is set up for automated code reviews on this repo. Configure here. |
|
The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #3494 +/- ##
==========================================
- Coverage 59.22% 58.19% -1.03%
==========================================
Files 2214 2130 -84
Lines 183389 173272 -10117
==========================================
- Hits 108604 100832 -7772
+ Misses 64994 63479 -1515
+ Partials 9791 8961 -830
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 6b0b9fc. Configure here.
| return true, nil | ||
| }) | ||
| s.Require().Error(err) | ||
| s.Require().Contains(err.Error(), "scanned more than") |
There was a problem hiding this comment.
Could we add a selective-filter case here? The scan-limit tests are all all-pass / all-fail, but the bug this PR actually fixes is the mid-page one — a ~1-in-N filter that needs a few thousand raw scans to fill its page should still succeed, not get rejected. That's the regression I'd want pinned so we don't quietly bring back the old totalIter-vs-end comparison.
|
|
||
| func (suite *IntegrationTestSuite) TestGetPaginatedTotalSupplyMaxLimitExceeded() { | ||
| _, _, err := suite.app.BankKeeper.GetPaginatedTotalSupply(suite.ctx, &query.PageRequest{Limit: query.MaxLimit + 1}) | ||
| suite.Require().Error(err) |
There was a problem hiding this comment.
Can we add a multi-page case here for 1,000 denoms asserting collectAllTotalSupply returns the full set? Everything's single-page today, so the cross-page merge is untested, and that's the path feeding the in-consensus TotalSupply invariant. Given the fork stakes I'd want it covered.
| // Scan limits: to prevent unbounded store walks, this function caps iteration at MaxScanLimit | ||
| // entries both before the page is filled (Phase 1) and after (Phase 2). If the cap is hit: | ||
| // - Phase 1 (page not yet full): returns an error — the page could not be assembled. | ||
| // - Phase 2, CountTotal=true: returns an error — the total count could not be determined. | ||
| // - Phase 2, CountTotal=false: returns the assembled page with NextKey=nil. This does NOT | ||
| // guarantee there are no further results; it means no next filtered hit was found within | ||
| // MaxScanLimit entries past the page boundary. Callers that require reliable full traversal | ||
| // of sparse datasets should use key-based pagination instead. |
There was a problem hiding this comment.
Slight concern with the growing size of these comments. A bit of a smell to me suggesting we could make the code more expressive so that it effectively explains itself. It does already include comments in the implementation
|
THere is some overlap between this PR and #3524 that's worth distilling before review |

Problem
Three separate vectors allow a single RPC call to trigger unbounded KV store iteration:
MaxLimitwasmath.MaxUint64; callers could request billions of items in one call.count_total=trueunbounded scan — after serving the requested page, the paginator continued iterating the entire remaining store just to populatepagination.total. Implicitlimit=0also silently enabled this behaviour.pagination.offset; a caller withoffset=1_000_000_000forces the iterator to skip a billion entries before serving a single result.GetBlockWithTxsallocation — user-suppliedlimitwas passed directly intomake([]*txtypes.Tx, 0, limit)before any validation.Changes
sei-cosmos/types/query/pagination.goMaxLimitto1_000MaxOffset = 10_000andVerifyPaginationOffset(); enforced inParsePaginationandpaginate()MaxScanLimit = 10_000— fires whencount_total=trueand the iterator travels more thanMaxScanLimitentries past the end of the requested page (count > end + MaxScanLimit), preventing full-store counts while still allowingcount_totalon reasonably-sized storescountTotal = trueside-effect whenlimit == 0; callers must opt in explicitlysei-cosmos/types/query/filtered_pagination.goMaxOffsetandMaxScanLimitguards applied toFilteredPaginateandGenericFilteredPaginatetotalIter(raw store iterations) was compared againstend(a filtered-hit count), causing the limit to fire mid-page for selective filters — a query with a 1% pass rate andlimit=100could be rejected before accumulating its first resultnumHits < end): caps raw iterations atend + MaxScanLimit— prevents full-store walks when the filter produces too few hits to fill the page (no-hits DoS path); cannot fire once the page starts completingnumHits >= end): tracks iterations after page completion viapageCompleteIterand caps atMaxScanLimit— limits post-pagecount_totalscanning without any risk of mid-page interferencesei-cosmos/x/auth/tx/service.gopagination.VerifyPaginationLimit(limit)beforemake([]*txtypes.Tx, 0, limit)inGetBlockWithTxsBehaviour summary
limit > 1,000InvalidArgumentlimit = 0count_total=truecount_total=true, store has >end + 10,000entriesInvalidArgumentcount_total=true, selective filter,numHits < endend + MaxScanLimitraw iterationsoffset > 10,000InvalidArgumentGetBlockWithTxswithlimit=1_000_000_000InvalidArgumentRegression risk
Breaking change for clients sending
limit > 1,000,offset > 10,000, or relying onlimit=0to return a total count. Clients should uselimit ≤ 1,000, follownext_keyfor subsequent pages, and setcount_total=trueexplicitly only when the store is known to be small.ExportGenesisand theTotalSupplyinvariant were migrated tocollectAllTotalSupply, which internally paginates atMaxLimitper page to collect all denominations without being blocked by the new limit.🤖 Generated with Claude Code