PLT-819: Wire rate limiter + method parser into EVM JSON-RPC HTTP middleware (:8545)#3779
PLT-819: Wire rate limiter + method parser into EVM JSON-RPC HTTP middleware (:8545)#3779amir-deris wants to merge 3 commits into
Conversation
PR SummaryMedium Risk Overview Config: New HTTP stack: Metrics: Reviewed by Cursor Bugbot for commit f6c1bb5. 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).
|
| return true, "", true, nil | ||
| case parseErr != nil: | ||
| return false, "", false, parseErr | ||
| } |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit eca7830. Configure here.
There was a problem hiding this comment.
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.
| 1, | ||
| metric.WithAttributes( | ||
| attribute.String("plane", plane), | ||
| attribute.String("method", method), |
There was a problem hiding this comment.
[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.
| methods, _, parseErr := g.parser.Parse(body) | ||
| switch { | ||
| case errors.Is(parseErr, ratelimiter.ErrProbeLimit): | ||
| return true, "", true, nil |
There was a problem hiding this comment.
[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 Report❌ Patch coverage is Additional details and impacted files@@ 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
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.
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.
There was a problem hiding this comment.
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).
❌ 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} |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit f6c1bb5. Configure here.
There was a problem hiding this comment.
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.
|
|
||
| rateLimitRegistry *ratelimiter.Registry | ||
|
|
There was a problem hiding this comment.
🟡 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:
NewEVMHTTPServerconstructsrateLimitRegistry := ratelimiter.New(...), stores it onhttpServer.rateLimitRegistry, and also passes it intoNewRateLimitGate(rateLimitRegistry, ...)which stores it asRateLimitGate.registry.httpServer.EnableRPC(apis, httpConfig)runs; sinceh.rateLimitRegistryis already non-nil, theh.rateLimitRegistry == nilbranch at rpcstack.go:367 is false and its write never executes on this path.- For every subsequent HTTP request,
rateLimitMiddleware.ServeHTTPandRateLimitGate.Checkboth go throughm.gate.registry/g.registry, never throughh.rateLimitRegistry. - No other method on
HTTPServer(Stop, doStop, ServeHTTP, etc.) referencesh.rateLimitRegistry. - 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.


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-forkPreDispatchHookis nontrivial cross-repo work with its own edge cases.ratelimiter.Allownow takes amethodargument and records it as amethodattribute onrpc_rate_limit_rejected_total{plane,method}, satisfying the WBD's metrics contract.evmrpc/config):rate_limiting_enabled(defaulttrue, stabilisation escape hatch),trusted_proxy_cidrs(default empty/trust-none),rate_limit_probe_bytes(default 1 MiB, boundsMethodParser's partial read independent ofmax_request_body_bytes). AConfig.RateLimiterConfig()helper builds theratelimiter.Configused by admission.rateLimitMiddleware(evmrpc/rate_limit_middleware.go) sits outermost in the HTTP handler stack, ahead of the existingrequestSizeLimiter:rateLimitMiddleware → requestSizeLimiter → wrapSeiLegacyHTTP → NewHTTPHandlerStackIt reads only up to
rate_limit_probe_bytesto extract JSON-RPC method name(s) viaMethodParser, applies the per-IP token bucket per method, restores the body for downstream handlers, and rejects with plain HTTP 429"too many requests"(matchingrequestSizeLimiter's"server busy"convention) rather than a JSON-RPC error body.requestSizeLimiter).ErrProbeLimit(method not found within the probe budget) passes through without a rate-limit decision — accepted Phase-1 risk, documented.RateLimitGate(evmrpc/rate_limit.go) is the shared decision helper used by the middleware.rejectReasonRateLimitedconstant inevmrpc/metrics.go, alongside the existingrejectReasonOversize/rejectReasonBusy, so rate-limit rejections are distinguishable from size-budget rejections in thereasonmetric attribute.*ratelimiter.Registryis constructed perHTTPServerinstance inNewEVMHTTPServer.Out of scope / follow-ups
:8546) wiring — tracked as 1b-ws.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:Allowwith 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,ErrProbeLimitpassthrough, parse-error rejection, body restored for inner handler, disabled bypass, non-POST passthroughTestComposedStack_OverLimitRejectedConsistentlyRateLimiterConfig()helpergofmt -s -l ./goimports -l .cleango build ./evmrpc/... ./ratelimiter/...