Skip to content

PLT-819: Wire rate limiter + method parser into EVM JSON-RPC HTTP middleware (:8545)#3779

Open
amir-deris wants to merge 3 commits into
mainfrom
amir/plt-819-wire-ratelimiter-method-parser-evm-jsonrpc
Open

PLT-819: Wire rate limiter + method parser into EVM JSON-RPC HTTP middleware (:8545)#3779
amir-deris wants to merge 3 commits into
mainfrom
amir/plt-819-wire-ratelimiter-method-parser-evm-jsonrpc

Conversation

@amir-deris

@amir-deris amir-deris commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements Phase 1b-core of the RPC endpoint protection work: wires ratelimiter.Registry + ratelimiter.MethodParser (from 1a) into the EVM JSON-RPC HTTP admission path (:8545). WebSocket (:8546) is out of scope for this PR — split into a 1b-ws follow-up since the go-ethereum-fork PreDispatchHook is nontrivial cross-repo work with its own edge cases.

  • ratelimiter.Allow now takes a method argument and records it as a method attribute on rpc_rate_limit_rejected_total{plane,method}, satisfying the WBD's metrics contract.
  • New config fields (evmrpc/config): rate_limiting_enabled (default true, stabilisation escape hatch), trusted_proxy_cidrs (default empty/trust-none), rate_limit_probe_bytes (default 1 MiB, bounds MethodParser's partial read independent of max_request_body_bytes). A Config.RateLimiterConfig() helper builds the ratelimiter.Config used by admission.
  • New rateLimitMiddleware (evmrpc/rate_limit_middleware.go) sits outermost in the HTTP handler stack, ahead of the existing requestSizeLimiter:
    rateLimitMiddleware → requestSizeLimiter → wrapSeiLegacyHTTP → NewHTTPHandlerStack
    It reads only up to rate_limit_probe_bytes to extract JSON-RPC method name(s) via MethodParser, applies the per-IP token bucket per method, restores the body for downstream handlers, and rejects with plain HTTP 429 "too many requests" (matching requestSizeLimiter's "server busy" convention) rather than a JSON-RPC error body.
    • Batch requests: whole-batch rejected if any method exhausts the bucket (same atomic-request treatment as requestSizeLimiter).
    • ErrProbeLimit (method not found within the probe budget) passes through without a rate-limit decision — accepted Phase-1 risk, documented.
    • Other parse errors are rejected (fail-closed).
  • New RateLimitGate (evmrpc/rate_limit.go) is the shared decision helper used by the middleware.
  • New rejectReasonRateLimited constant in evmrpc/metrics.go, alongside the existing rejectReasonOversize/rejectReasonBusy, so rate-limit rejections are distinguishable from size-budget rejections in the reason metric attribute.
  • One *ratelimiter.Registry is constructed per HTTPServer instance in NewEVMHTTPServer.

Out of scope / follow-ups

  • WebSocket (:8546) wiring — tracked as 1b-ws.
  • Unifying method extraction with sei_legacy_http.go's existing full-body parse (a single request is currently parsed for "method" twice by two independent code paths) — tracked as a follow-up, not a blocker.

Test plan

  • ratelimiter: Allow with method label recorded on the OTel counter (manual reader test)
  • evmrpc: allow under limit, burst then 429, per-IP isolation, batch counts all methods, whole-batch reject, ErrProbeLimit passthrough, parse-error rejection, body restored for inner handler, disabled bypass, non-POST passthrough
  • Composed stack test mirroring TestComposedStack_OverLimitRejectedConsistently
  • Config: new fields parse correctly, defaults verified, RateLimiterConfig() helper
  • gofmt -s -l . / goimports -l . clean
  • go build ./evmrpc/... ./ratelimiter/...

@cursor

cursor Bot commented Jul 20, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes default HTTP admission for all :8545 traffic (enabled by default); misconfigured trusted-proxy CIDRs could skew client IP bucketing, though the feature can be disabled via config.

Overview
Adds per-IP token-bucket rate limiting on the EVM JSON-RPC HTTP path before full body decode, using the shared ratelimiter registry and a bounded probe read to extract method names (including one token per method in batches).

Config: New rate_limiting_enabled (default on), trusted_proxy_cidrs, and rate_limit_probe_bytes, plus Config.RateLimiterConfig() for admission. Existing ip_rate_limit_rps / ip_rate_limit_burst drive the bucket.

HTTP stack: requestSizeLimiter stays outermost (413 on declared oversize before probe I/O); inside that, rateLimitMiddleware + RateLimitGate resolve client IP (trusted-proxy XFF), charge the bucket, restore the body for downstream handlers, and return HTTP 429 / 400 (not JSON-RPC errors). Rejections are tracked via rate_limited and unparseable on evmrpc_requests_rejected_total.

Metrics: Registry.Allow now takes a method; rejections emit rpc_rate_limit_rejected_total{plane,method} with namespace-bucketed labels via new bucketRPCMethod.

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

@amir-deris amir-deris changed the title Added wiring for rate limiter and method parser for evm plane PLT-819: Wire rate limiter + method parser into EVM JSON-RPC HTTP middleware (:8545) Jul 20, 2026
@github-actions

Copy link
Copy Markdown

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

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 20, 2026, 1:01 PM

Comment thread ratelimiter/registry.go Outdated
Comment thread evmrpc/rate_limit_middleware.go
Comment thread evmrpc/rpcstack.go
Comment thread evmrpc/rate_limit.go Outdated
Comment thread evmrpc/rate_limit.go
return true, "", true, nil
case parseErr != nil:
return false, "", false, parseErr
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Zero RPS still rejects parses

Medium Severity

ip_rate_limit_rps = 0 is documented as disabling per-IP limiting so requests pass through, and Registry.Allow honors that, but the HTTP middleware still runs whenever rate_limiting_enabled is true. Bodies are probed and parse failures still return HTTP 400, so zero RPS is not a full bypass.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit eca7830. Configure here.

@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 PR cleanly wires the existing per-IP rate limiter and JSON-RPC method parser into the EVM HTTP admission stack with thorough tests, but the rejection metric records the attacker-controlled JSON-RPC method as an unbounded label, which is a metric-cardinality DoS vector that should be bounded before merge. The probe-limit passthrough is a documented, accepted Phase-1 bypass worth noting.

Findings: 1 blocking | 4 non-blocking | 2 posted inline

Blockers

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

Non-blocking

  • cursor-review.md is empty (no Cursor output produced); Codex's two findings are both incorporated below. No prompt-injection content was found in the diff or PR description.
  • HTTPServer.rateLimitRegistry (evmrpc/rpcstack.go:107) is written in both server.go and EnableRPC but never read anywhere — dead state. Either remove the field or document why it's retained.
  • Parse-error rejections (rate_limit_middleware.go: checkErr != nil path) return HTTP 400 but do not call recordRequestRejected, so fail-closed rejections are invisible in the reject-reason metric while rate-limit rejections are counted. Consider recording a reason there too for observability parity.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread ratelimiter/registry.go Outdated
1,
metric.WithAttributes(
attribute.String("plane", plane),
attribute.String("method", method),

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] method is attacker-controlled: the JSON-RPC method name is parsed straight from the request body with no allowlist/validation, and it's now recorded as an OpenTelemetry attribute on every rejection. Once an IP's bucket is exhausted (a couple of requests), an attacker can flood requests each carrying a unique random method string; each rejection creates a new rpc_rate_limit_rejected_total{plane,method} time series, causing unbounded metric-series growth that can exhaust node memory or overwhelm the metrics backend. Bound the cardinality before recording — e.g. only emit the label for a known/registered method set and collapse everything else to a constant like "other" (or drop the method label entirely). This is the more severe of Codex's two findings.

Comment thread evmrpc/rate_limit.go Outdated
methods, _, parseErr := g.parser.Parse(body)
switch {
case errors.Is(parseErr, ratelimiter.ErrProbeLimit):
return true, "", true, nil

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] Probe-limit passthrough bypass: when the method isn't found within rate_limit_probe_bytes (default 1 MiB) the request is admitted with no token charged. Because JSON object key order is attacker-controlled, a client can pad params ahead of method to push the method name past the 1 MiB probe window while staying under the 5 MiB body limit, defeating rate limiting for large-body request floods. The PR documents this as an accepted Phase-1 risk, so not a merge blocker, but worth an explicit follow-up: a body that exhausts the probe budget without yielding a method could reasonably be charged a token (or rejected) rather than passed through free. Matches Codex's first finding.

@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.01266% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.15%. Comparing base (4a9705e) to head (eca7830).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
evmrpc/config/config.go 57.14% 3 Missing and 3 partials ⚠️
evmrpc/rate_limit_middleware.go 89.28% 2 Missing and 1 partial ⚠️
evmrpc/rate_limit.go 90.90% 1 Missing and 1 partial ⚠️
evmrpc/rpcstack.go 33.33% 1 Missing and 1 partial ⚠️
evmrpc/server.go 80.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3779      +/-   ##
==========================================
- Coverage   60.11%   59.15%   -0.97%     
==========================================
  Files        2302     2212      -90     
  Lines      192033   181633   -10400     
==========================================
- Hits       115445   107448    -7997     
+ Misses      66267    64709    -1558     
+ Partials    10321     9476     -845     
Flag Coverage Δ
sei-chain-pr 70.01% <81.01%> (?)
sei-db 70.41% <ø> (ø)
sei-db-state-db ?

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

Files with missing lines Coverage Δ
evmrpc/metrics.go 96.82% <ø> (ø)
evmrpc/request_limiter.go 92.00% <ø> (ø)
ratelimiter/registry.go 97.77% <100.00%> (+0.02%) ⬆️
evmrpc/rate_limit.go 90.90% <90.90%> (ø)
evmrpc/rpcstack.go 78.57% <33.33%> (-0.47%) ⬇️
evmrpc/server.go 88.79% <80.00%> (-0.40%) ⬇️
evmrpc/rate_limit_middleware.go 89.28% <89.28%> (ø)
evmrpc/config/config.go 71.25% <57.14%> (-1.30%) ⬇️

... and 93 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.

@claude claude 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.

Beyond the reported findings, I also checked whether a batch JSON-RPC request that is ultimately rejected still burns rate-limit tokens for the methods that were checked before the exhausting one (evmrpc/rate_limit.go's Check loop) — this matches requestSizeLimiter's existing atomic-reject behavior and isn't a new issue introduced by this PR, so I ruled it out as a real bug.

Extended reasoning...

Given the reported cardinality-explosion bug on the rejection metric, this PR needs human review before merge regardless; this note is purely to record that the batch-token-burn candidate was independently examined and ruled out so it isn't re-explored in a future pass.

Comment thread ratelimiter/registry.go
Comment thread evmrpc/rate_limit.go
Comment thread evmrpc/rate_limit_middleware.go Outdated

@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 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

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 f6c1bb5. Configure here.

if gate == nil || !gate.enabled {
return inner
}
return &rateLimitMiddleware{inner: inner, gate: gate}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

RPS zero skips incomplete bypass

Medium Severity

newRateLimitMiddleware only skips when RateLimitingEnabled is false. With ip_rate_limit_rps or ip_rate_limit_burst at 0 — documented as disabling per-IP limiting — the middleware still probes the body and fail-closes parse errors with HTTP 400, even though Allow is a no-op. Operators using the existing RPS=0 disable path still get admission behavior changes and probe overhead.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f6c1bb5. Configure here.

@claude claude 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.

Previously flagged issues (unbounded method-label cardinality, rate_limit_probe_bytes overflow, and the rate-limiter probe running ahead of the size-limiter's oversize check) are fixed in the latest commits (717a425, f6c1bb5), with new regression tests covering each. Beyond the inline nit found this run, I also checked and ruled out three candidate issues: the ip_rate_limit_rps=0 bypass claim is a doc-wording gap, not a functional one (the probe still runs, no token is charged); parse errors are rejected fail-closed, not bypassed; and batch requests charge tokens per-method before the whole-batch reject, so it's not a partial-debit bug.

Extended reasoning...

This PR wires per-IP token-bucket rate limiting into the EVM JSON-RPC HTTP admission path (:8545) — a security-relevant admission-control change. It went through multiple review rounds (Cursor, seidroid, and my own prior pass) that surfaced real issues: unbounded OTel label cardinality from attacker-controlled method strings, an int64 overflow in the probe-byte clamp that could 400 every request, and the rate limiter reading probe bytes before the size-limiter's cheap Content-Length check (defeating its zero-byte-read fast-reject for oversized bodies). All three are fixed in commits 717a425 and f6c1bb5, each with a corresponding regression test (bucketRPCMethod bucketing, MaxInt64 clamp test, and TestComposedStack_OversizeContentLengthBeforeProbeRead). This run's own bug-hunting pass found only a minor nit (an unused struct field), and I additionally verified the three ruled-out candidates above from finder output. Given the security sensitivity of this admission path and the history of substantive findings, a human should do a final confirmation pass even though nothing outstanding remains open.

Comment thread evmrpc/rpcstack.go
Comment on lines 106 to +108

rateLimitRegistry *ratelimiter.Registry

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 HTTPServer.rateLimitRegistry (evmrpc/rpcstack.go:107) is written in two places (server.go:242 and rpcstack.go:367-368) but never read anywhere — the live registry used for admission decisions is reached exclusively through RateLimitGate.registry via config.rateLimitGate. This is dead state; consider removing the field entirely, or wiring it to an actual consumer (e.g. graceful shutdown/stats).

Extended reasoning...

HTTPServer.rateLimitRegistry is a *ratelimiter.Registry field declared at evmrpc/rpcstack.go:107. It is assigned directly in NewEVMHTTPServer (evmrpc/server.go:242, httpServer.rateLimitRegistry = rateLimitRegistry), and again — redundantly — inside EnableRPC at evmrpc/rpcstack.go:367-368:

if config.rateLimitGate != nil && h.rateLimitRegistry == nil {
    h.rateLimitRegistry = config.rateLimitGate.registry
}

A grep across evmrpc/ for rateLimitRegistry turns up exactly these occurrences: the field declaration, the two write sites, and the h.rateLimitRegistry == nil check on line 367 that exists solely to decide whether to perform the write on line 368. There is no third site anywhere that reads h.rateLimitRegistry for any functional purpose (no shutdown hook, no stats/metrics exposition, nothing passed to another component).

Because NewEVMHTTPServer always sets httpServer.rateLimitRegistry before calling EnableRPC, the field is already non-nil by the time EnableRPC runs in the only current call path, so the guarded assignment at rpcstack.go:367-368 is itself dead code on top of being an otherwise-unread field.

The actual registry instance that drives admission decisions is not this field — it's reached through RateLimitGate.registry, stored inside the *RateLimitGate referenced by HTTPConfig.rateLimitGate, and used directly inside RateLimitGate.Check (calling g.registry.Allow(...)) and inside rateLimitMiddleware.ServeHTTP (via m.gate.registry.IPFromHTTPRequest(r)). So HTTPServer.rateLimitRegistry is a second, unused owner of the same *ratelimiter.Registry pointer — it doesn't do anything, but it does create a plausible-looking second "source of truth" that could mislead a future maintainer into thinking HTTPServer owns/exposes the registry when in fact all real interaction goes through RateLimitGate.

Step-by-step proof it's dead:

  1. NewEVMHTTPServer constructs rateLimitRegistry := ratelimiter.New(...), stores it on httpServer.rateLimitRegistry, and also passes it into NewRateLimitGate(rateLimitRegistry, ...) which stores it as RateLimitGate.registry.
  2. httpServer.EnableRPC(apis, httpConfig) runs; since h.rateLimitRegistry is already non-nil, the h.rateLimitRegistry == nil branch at rpcstack.go:367 is false and its write never executes on this path.
  3. For every subsequent HTTP request, rateLimitMiddleware.ServeHTTP and RateLimitGate.Check both go through m.gate.registry / g.registry, never through h.rateLimitRegistry.
  4. No other method on HTTPServer (Stop, doStop, ServeHTTP, etc.) references h.rateLimitRegistry.
  5. Therefore the field can be deleted (along with its two write sites) with zero behavioral change, confirming it is dead state.

This is purely a maintainability/clarity issue with no effect on runtime behavior, correctness, or the rate-limiting feature itself — it doesn't cause incorrect admission decisions, doesn't leak memory beyond one unused pointer per HTTPServer, and nothing crashes. All independent verifiers that examined it converged on the same conclusion and rated it "nit," consistent with the Cursor Bugbot "Low Severity" classification of the same finding. Recommended fix: delete HTTPServer.rateLimitRegistry and its two assignment sites, or if a future use case (e.g. graceful shutdown draining, exposing registry stats) is intended, wire it to that consumer now rather than leaving it write-only.

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