Skip to content

Add per-IP rate limiting to CometBFT RPC HTTP - #3911

Open
amir-deris wants to merge 17 commits into
mainfrom
amir/plt-981-rate-limiter-comet-bft
Open

Add per-IP rate limiting to CometBFT RPC HTTP#3911
amir-deris wants to merge 17 commits into
mainfrom
amir/plt-981-rate-limiter-comet-bft

Conversation

@amir-deris

@amir-deris amir-deris commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds optional per-IP rate-limit admission middleware to CometBFT RPC HTTP (:26657).

  • POST / — JSON-RPC bodies are parsed for method names before dispatch; oversize/malformed requests are rejected at the gate (HTTP 413/400). Rate-limit rejections return JSON-RPC error objects with HTTP 429 so clients can decode them normally.
  • GET/HEAD URI routes (e.g. /status, /websocket handshake) — charged by path-derived method name. Empty-body GET/HEAD/POST on / bypass the gate so the RPC method catalog page keeps working.
  • WebSocket — only the HTTP upgrade handshake (GET /websocket) is rate-limited (method websocket). Frames after upgrade are not covered by this middleware.
  • Inspect mode — invalid trusted-proxy-cidrs logs an error and disables the gate instead of panicking.

New [rpc] config keys:

Key Purpose
ip-rate-limit-rps Sustained per-IP request rate (req/s); 0 disables throttling
ip-rate-limit-burst Per-IP burst size; must be ≥ 10 when rate limiting is enabled and both RPS/burst are positive
rate-limiting-enabled Master switch for admission middleware (default false)
trusted-proxy-cidrs CIDRs whose X-Forwarded-For headers are trusted for client IP resolution

Test plan

  • go test ./sei-tendermint/rpc/jsonrpc/server/ -run TestRateLimit
  • go test ./sei-tendermint/config/ -run TestRPCConfig
  • Oversize body → 413, malformed JSON → 400 (JSON-RPC), burst < 10 ValidateBasic rejection
  • GET / method catalog passthrough and HTML catalog through registered routes
  • POST 429 returns JSON-RPC error body; per-IP isolation; URI path extraction; disabled gate bypass
  • Inspect handler survives invalid trusted-proxy CIDR configuration

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedAug 14, 2026, 4:12 PM

@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.06383% with 45 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.32%. Comparing base (a34ceab) to head (ff787ad).

Files with missing lines Patch % Lines
...ermint/rpc/jsonrpc/server/rate_limit_middleware.go 79.26% 13 Missing and 4 partials ⚠️
sei-tendermint/rpc/jsonrpc/server/rate_limit.go 72.09% 6 Missing and 6 partials ⚠️
sei-tendermint/internal/rpc/core/env.go 25.00% 8 Missing and 1 partial ⚠️
sei-tendermint/rpc/jsonrpc/server/http_server.go 69.23% 3 Missing and 1 partial ⚠️
sei-tendermint/internal/inspect/inspect.go 33.33% 1 Missing and 1 partial ⚠️
sei-tendermint/internal/inspect/rpc/rpc.go 92.85% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3911      +/-   ##
==========================================
- Coverage   59.48%   58.32%   -1.17%     
==========================================
  Files        2325     2227      -98     
  Lines      198660   186707   -11953     
==========================================
- Hits       118180   108889    -9291     
+ Misses      69240    67529    -1711     
+ Partials    11240    10289     -951     
Flag Coverage Δ
sei-chain-pr 60.14% <76.06%> (?)
sei-db 70.41% <ø> (-0.22%) ⬇️
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
ratelimiter/method_parser.go 90.74% <100.00%> (+0.44%) ⬆️
sei-tendermint/config/config.go 77.42% <100.00%> (+0.54%) ⬆️
sei-tendermint/config/toml.go 57.62% <ø> (ø)
...tendermint/rpc/jsonrpc/server/http_json_handler.go 90.36% <100.00%> (ø)
sei-tendermint/rpc/jsonrpc/types/types.go 70.07% <ø> (+1.57%) ⬆️
sei-tendermint/internal/inspect/rpc/rpc.go 67.10% <92.85%> (+4.98%) ⬆️
sei-tendermint/internal/inspect/inspect.go 59.42% <33.33%> (-1.78%) ⬇️
sei-tendermint/rpc/jsonrpc/server/http_server.go 71.00% <69.23%> (+0.49%) ⬆️
sei-tendermint/internal/rpc/core/env.go 74.58% <25.00%> (-3.78%) ⬇️
sei-tendermint/rpc/jsonrpc/server/rate_limit.go 72.09% <72.09%> (ø)
... and 1 more

... and 199 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

amir-deris and others added 3 commits August 13, 2026 13:12
Use kebab-case [rpc] keys so viper reads operator config.toml, align CheckURI
parse failures with CheckPOST (400 vs 429), and stop inspect from panicking on
invalid trusted-proxy CIDRs when rate limiting is enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
POST / admission failures that clients must decode now respond with
JSON-RPC error objects (including HTTP 429 on throttle) instead of
plain text. Add coverage for oversize bodies, malformed JSON, burst
validation, and GET / method catalog passthrough.

Co-authored-by: Cursor <cursoragent@cursor.com>
@amir-deris amir-deris changed the title Work in progress: initial implementation Add per-IP rate limiting to CometBFT RPC HTTP Aug 13, 2026
@amir-deris
amir-deris marked this pull request as ready for review August 13, 2026 11:35
@cursor

cursor Bot commented Aug 13, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches the public RPC HTTP path (client IP via optional X-Forwarded-For trust, early rejects, and 429 behavior); defaults keep it disabled but misconfiguration behind proxies could rate-limit the wrong clients.

Overview
Introduces optional per-IP rate-limit admission on CometBFT RPC HTTP (:26657), wired from new [rpc] settings (ip-rate-limit-rps, ip-rate-limit-burst, rate-limiting-enabled, trusted-proxy-cidrs). The master switch defaults to off; when enabled, a RateLimitMiddleware runs before dispatch on main and inspect RPC handlers (invalid trusted-proxy-cidrs fails handler construction).

The gate uses the shared ratelimiter registry: POST / bodies are bounded by max-body-bytes, parsed for JSON-RPC method names (including batch token charging), and rejected with JSON-RPC errors on 400/413/429; non-root URI routes are charged by path segment; empty-body GET/HEAD/POST on / use a catalog bucket; OPTIONS is exempt. Malformed JSON and oversize bodies still consume the invalid-method bucket so abuse cannot skip limits.

MethodParser now treats non-positive maxProbeBytes as unlimited (no implicit DefaultMaxProbeBytes), with parsing refactored to support bounded and unbounded reads. RequestBatchSizeLimit (10) moves to rpctypes and config validation requires burst ≥ that limit when rate limiting is enabled with a positive burst.

Reviewed by Cursor Bugbot for commit ff787ad. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5dd7838. Configure here.

Comment thread sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go
Comment thread sei-tendermint/internal/rpc/core/env.go Outdated

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-IP admission gate is well-tested and correctly fail-closed on malformed bodies, but as written it rejects URI-style POST requests (a supported CometBFT access pattern) with 400, silently converts max-body-bytes = 0 (unlimited) into a 1 MB 413 limit while draining oversize bodies unbounded, and leaves a free unmetered path via empty-body / and OPTIONS. Config validation also lets the master switch be enabled with zero/negative rates, which disables throttling without any signal.

Findings: 3 blocking | 13 non-blocking | 11 posted inline

Blockers

  • None at the file/PR level.
  • 3 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • The Cursor second-opinion file (cursor-review.md) is empty — that pass produced no output, so this review merges only Claude's and Codex's findings.
  • Client-visible hardening not called out in the PR description: MethodParser is fail-closed, so bodies CometBFT previously tolerated now get a single HTTP 400 instead of per-element JSON-RPC errors — missing method, duplicate method keys, empty batch [], and trailing data after the top-level value. Worth a release note for operators enabling the gate.
  • Metric usefulness on the new plane is limited: bucketRPCMethod only recognizes EVM-style namespaces, so almost every CometBFT method (status, block, broadcast_tx_sync, and all path-derived URI names) collapses to method_namespace="other". Only abci_* maps to a real namespace. Consider adding the CometBFT method names/namespaces to knownRPCNamespaces, or the metric will not tell an operator what is being throttled.
  • Test gaps worth closing: (1) URI-style POST (POST /status?height=1, form-encoded POST) through the gate; (2) max-body-bytes = 0 (unlimited) config, which is where the gate's own 1 MB limit and the unbounded drain bite; (3) X-Forwarded-For / trusted-proxy IP resolution end-to-end through the middleware (currently only covered inside the ratelimiter package); (4) a batch larger than burst, which AllowN rejects unconditionally.
  • RateLimitGate.enabled is always true at both production call sites, so NewRateLimitMiddleware's nil check and every if !g.enabled early return express the same "off" state twice. Per AGENTS.md ("guard at the choke point"), a nil gate is the single choke point; dropping the flag removes three dead branches and one redundant way to misconfigure the gate in future callers.
  • 8 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go Outdated
Comment thread sei-tendermint/rpc/jsonrpc/server/rate_limit.go Outdated
Comment thread sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go Outdated
Comment thread sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go
Comment thread sei-tendermint/internal/rpc/core/env.go Outdated
Comment thread sei-tendermint/config/config.go Outdated
Comment thread sei-tendermint/config/config.go Outdated
Comment thread sei-tendermint/internal/inspect/rpc/rpc.go Outdated
Comment thread sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go Outdated
Comment thread sei-tendermint/rpc/jsonrpc/server/rate_limit_middleware.go Outdated
@amir-deris

Copy link
Copy Markdown
Contributor Author

@seidroid review

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This revision closes the earlier URI-routing, max-body-bytes = 0, unbounded-drain, catalog-exemption, per-listener-registry and inspect fail-open findings, and the CORS/gate ordering is now correct in both env.go and inspect. One unmetered path remains: every OPTIONS request bypasses the gate while CometBFT's mux serves RPC on any HTTP verb, so the limiter is bypassable by changing the method.

Findings: 1 blocking | 5 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Config validation still accepts negative ip-rate-limit-rps / ip-rate-limit-burst while rate-limiting-enabled = true, and Registry.Allow treats non-positive values as "always allow". Noting the author's decision to match evmrpc, the practical footgun is the zero case: an operator adding rate-limiting-enabled = true to a pre-existing config.toml with none of the new keys gets RPS = 0, Burst = 0 from Viper, passes ValidateBasic, and runs with the middleware active but no throttling at all — no error, no log line. A startup warning when the switch is on and the bucket is disabled would cost nothing.
  • Rejection metrics are near-useless on this plane: bucketRPCMethod only recognizes EVM-style namespaces, so status, block, broadcast_tx_sync, catalog, and every path-derived URI name collapse to method_namespace="other" (only abci_* maps). Adding the CometBFT namespaces to knownRPCNamespaces would let an operator see what is actually being throttled.
  • Test gaps worth closing: (1) X-Forwarded-For / trusted-proxy IP resolution end-to-end through the middleware (currently only covered inside ratelimiter); (2) a JSON-RPC batch larger than burst, which AllowN rejects unconditionally — with RequestBatchSizeLimit = 10 and burst ≥ 10 enforced this is safe today, but nothing pins it; (3) gate enabled with ip-rate-limit-rps = 0, the config where admission runs but nothing throttles.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.


// isCometBFTRateLimitExemptRequest reports requests that should bypass the gate.
func isCometBFTRateLimitExemptRequest(r *http.Request) bool {
return r.Method == http.MethodOptions

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocker] This exempts every OPTIONS request on every path, and CometBFT does not restrict routes by HTTP method: rpc_func.go:28 registers mux.HandleFunc("/"+name, ...) and makeHTTPHandler never checks req.Method — it just calls parseURLParams (which reads query and form params) and dispatches. Path / is the same: handleInvalidJSONRPCPaths only compares the path.

So with the gate enabled, OPTIONS /status?height=1, OPTIONS /block?height=1, OPTIONS /broadcast_tx_sync?tx=0x..., and OPTIONS / with a JSON-RPC body are all fully served without consuming a token. Changing the verb defeats the whole feature.

Nothing else catches these: CORS is disabled by default (CORSAllowedOrigins empty), and when it is enabled cors.Handler now wraps the gate from the outside (env.go:409, inspect/rpc/rpc.go:88) and terminates genuine preflights — those with Access-Control-Request-Method — before they ever reach this middleware. A bare OPTIONS /status has no such header and passes straight through rs/cors to the mux.

The exemption is therefore unnecessary for preflight and only creates the bypass. Drop it and let OPTIONS fall through to the catalog/URI/POST branches (or charge it a token under a fixed label). TestRateLimitMiddleware_OPTIONSExempt currently pins the bypass and should be inverted.

}
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodPost:
return r.ContentLength == 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Keying "is this the catalog page" on ContentLength == 0 makes the behavior transport-dependent. A body-less request sent with chunked transfer-encoding (or any request where the length is unknown) has ContentLength == -1, so it falls to the JSON-RPC branch, MethodParser.Parse fails on the empty stream, and the client gets HTTP 400 — where makeJSONRPCHandler previously served the endpoint list for len(b) == 0. Same for verbs outside this switch (PUT/DELETE on / with an empty body), which the base handler also treated as catalog requests.

Deciding after the body is read removes the dependence on the framing: on path /, if the buffered body is empty, charge the catalog bucket and forward; otherwise parse it as JSON-RPC. That is also the same signal the inner handler uses, so the two can't disagree. (Codex flags the chunked case as well.)

// RateLimitGate applies per-IP token-bucket rate limiting for CometBFT RPC HTTP
// requests. POST JSON-RPC bodies are parsed with MethodParser before full decode;
// GET URI routes are accounted by path-derived method names.
type RateLimitGate struct {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This file is a near-verbatim copy of evmrpc/rate_limit.go: the struct fields, chargeAdmissionRejection, and CheckPOST (vs. Check) are identical line for line, differing only in the maxBodyBytes <= 0 fallback and the plane string. The fail-closed charging rule — parse error charges MethodInvalid, and an exhausted bucket converts the parse error into a rate-limit rejection — is the load-bearing invariant of the whole design, and it now exists in two places that must be kept in sync by hand.

Both packages already import ratelimiter; hoisting the shared gate there (with plane and body-limit policy as parameters) would make it one invariant instead of a convention. Related: enabled is true at both production call sites (env.go:397, inspect/rpc/rpc.go:84), and NewRateLimitMiddleware already returns inner unchanged for a nil gate — so a nil gate is the single choke point and the flag adds three dead if !g.enabled branches plus a second way to misconfigure the gate. Same point as the earlier review; still applies.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant