Skip to content

feat(ratelimiter): add MethodParser for pre-decode JSON-RPC method extraction (PLT-800)#3755

Open
amir-deris wants to merge 6 commits into
mainfrom
amir/plt-800-method-parser
Open

feat(ratelimiter): add MethodParser for pre-decode JSON-RPC method extraction (PLT-800)#3755
amir-deris wants to merge 6 commits into
mainfrom
amir/plt-800-method-parser

Conversation

@amir-deris

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

Copy link
Copy Markdown
Contributor

Summary

Implements MethodParser (PLT-800), for the RPC Endpoint Protection work.

MethodParser extracts the JSON-RPC "method" field from a request body via a streaming partial read, before the full body is decoded into a Go request struct. This lets per-method rate-limit overrides and, later, weighted admission make a decision based on which method is being called without first paying the memory cost of materialising the whole request.

What it does

  • NewMethodParser(maxProbeBytes)Parse(io.Reader) (methods []string, batch bool, err error).
  • Uses a json.Decoder and skips every non-method field via a depth-tracked scan — nested objects/arrays in params are read off the stream but never decoded into Go structures. Each request object is read to its closing } so a duplicate "method" key can be detected and rejected (rather than returning at the first match, which could disagree with the last-wins value encoding/json would decode downstream — letting a body report one method while dispatching another).
  • Matches "method" case-insensitively, the same way encoding/json struct-tag decoding does downstream, so a body cannot report one method to the rate limiter while dispatching another.
  • Handles both JSON-RPC framings that need it: EVM JSON-RPC (:8545/:8546) and CometBFT RPC (:26657), which share the JSON-RPC 2.0 shape and the "method" field.
  • Supports batches (top-level arrays), returning one method per element in order with batch=true.
  • maxProbeBytes (default 1 MiB) bounds the work an adversarial body (a giant params) can force; exceeding it returns ErrProbeLimit, distinguished from a genuinely truncated/malformed body. Because each object is read to its end, a legitimate body larger than the probe limit also yields ErrProbeLimit — the wiring middleware (follow-up PRs) must treat that as "method undetermined, fall back to full decode" rather than a rejection.
  • Takes a plain io.Reader — the body tee/rewind decision stays with the wiring middleware.

Scope

  • No request-path wiring. Consumed by EVM (1b) and CometBFT (1c) HTTP/WS middleware in follow-up PRs.
  • gRPC (1d) is out of scope — gRPC already exposes the full method name in the interceptor, so no partial read is needed.

Tests

ratelimiter/method_parser_test.go — EVM/CometBFT single requests, both field orders, nested/empty/large params, escaped and case-insensitive method keys, batches (multi/single/method-after-params), duplicate-method rejection (single + batch + mixed-case), trailing-data rejection, every error sentinel (ErrNoMethod, ErrMethodNotString, ErrNotObject, ErrEmptyBatch, ErrDuplicateMethod, ErrTrailingData, ErrProbeLimit), truncated-vs-probe-limit disambiguation (including the exact-probe-limit boundary), and reading from a streaming io.Reader.

@cursor

cursor Bot commented Jul 14, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Security-relevant parsing rules (duplicate/case-variant method keys, probe limits) must be wired correctly in later middleware; mis-handling ErrProbeLimit could weaken per-method limits, but this change is isolated and not on the live request path yet.

Overview
Adds MethodParser in ratelimiter so JSON-RPC "method" names can be read from a request body via a bounded streaming scan before a full decode—intended for per-method rate limits and weighted admission on EVM/CometBFT HTTP paths in follow-up PRs.

Parse(io.Reader) returns method names for a single object or batch array, skips nested params without allocating them, enforces case-insensitive "method" keys aligned with encoding/json, rejects duplicate method keys (to avoid charging a cheap method while dispatching another), rejects trailing data after the top-level value, and maps oversized reads to ErrProbeLimit (default 1 MiB probe). Middleware is expected to treat probe-limit failures as “method unknown” and fall back to full decode—not reject outright.

Includes broad unit tests (EVM/CometBFT shapes, batches, malformed/truncated bodies, probe-boundary cases). No request-path wiring in this PR.

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

@amir-deris amir-deris changed the title Added streaming method parser and test feat(ratelimiter): add MethodParser for pre-decode JSON-RPC method extraction (PLT-800) Jul 14, 2026
@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

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

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 14, 2026, 4:11 PM

@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.03419% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.00%. Comparing base (49f4d34) to head (edde4ea).

Files with missing lines Patch % Lines
ratelimiter/method_parser.go 88.03% 8 Missing and 6 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3755      +/-   ##
==========================================
- Coverage   59.93%   59.00%   -0.93%     
==========================================
  Files        2288     2202      -86     
  Lines      189868   180100    -9768     
==========================================
- Hits       113793   106269    -7524     
+ Misses      65942    64480    -1462     
+ Partials    10133     9351     -782     
Flag Coverage Δ
sei-chain-pr 91.42% <88.03%> (?)
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 Δ
ratelimiter/method_parser.go 88.03% <88.03%> (ø)

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

Comment thread ratelimiter/method_parser.go Outdated
seidroid[bot]
seidroid Bot previously requested changes Jul 14, 2026

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

A well-structured, well-tested streaming JSON-RPC method parser, but it takes the first duplicate method key while Go's encoding/json (used by the downstream go-ethereum/CometBFT handlers) takes the last — a rate-limit-bypass discrepancy in a feature whose entire purpose is endpoint protection. A truncated batch is also silently accepted.

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

Blockers

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

Non-blocking

  • Cursor's second-opinion review file (cursor-review.md) was empty — that pass produced no output.
  • Minor edge case in classifyErr (method_parser.go:196): a well-formed body whose length exactly equals maxProbeBytes will exhaust the LimitedReader (lr.N <= 0) at the closing EOF and could be misreported as ErrProbeLimit rather than a clean parse/error. Low impact, but worth a test to pin the boundary behavior.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread ratelimiter/method_parser.go Outdated
// A valid JSON object always has string keys; anything else is malformed.
return "", ErrNotObject
}
if key == "method" && !found {

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] Taking the first method key diverges from how the request is actually executed. go-ethereum and CometBFT decode the body with Go's encoding/json, which for duplicate keys keeps the last value. So {"method":"cheap","method":"expensive"} is charged/rate-limited as cheap here but executed as expensive downstream — a rate-limit bypass in the exact feature this parser protects. Match the downstream semantics (take the last occurrence, i.e. keep overwriting method until the object ends) or reject objects containing a duplicate method key. The TestParse_DuplicateMethodTakesFirst test encodes the wrong invariant and should be updated accordingly. Note that with consumeRemainder=false (single-object path) you currently return at the first method and never even observe the duplicate, so a fix must also drain far enough to detect it.

return out, err
}
out = append(out, 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.

[suggestion] readBatchMethods never consumes or validates the closing ]. A truncated batch such as [{"method":"eth_call"} returns successfully (dec.More() reports false at EOF without erroring), so a malformed body is accepted as a valid batch. After the loop, read one more token and confirm it is the closing ] delim (returning a malformed-input error otherwise), and add a regression test for the truncated-batch case.

Comment thread ratelimiter/method_parser.go Outdated
Comment thread ratelimiter/method_parser.go
Comment thread ratelimiter/method_parser.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.

A well-structured, thoroughly-tested new utility (MethodParser) for pre-decode JSON-RPC method extraction, not yet wired into any request path. No blocking issues; two minor edge-case robustness improvements and a couple of documentation/description discrepancies to consider.

Findings: 0 blocking | 6 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • REVIEW_GUIDELINES.md (from the base branch) is empty, so no repo-specific review standards could be applied.
  • The Cursor second-opinion pass (cursor-review.md) produced no output — nothing to merge from it.
  • PR description overclaims behavior: it says the parser "returns as soon as method is found" and that tests include "a byte-counting reader proving the parser stops reading once method is found early." Neither is accurate — readMethodFromObject intentionally reads the object to its closing } (to detect a duplicate method, as the code comment correctly documents), and there is no byte-counting reader in method_parser_test.go. Consider aligning the description with the actual (correct) behavior.
  • Downstream-wiring consideration for the follow-up PRs: because each request object is read to its end, a legitimate large request whose body exceeds maxProbeBytes (e.g. a big eth_sendRawTransaction) yields ErrProbeLimit even when method appears first. The consuming EVM/CometBFT middleware must treat ErrProbeLimit as "fall back to full decode," not "reject," to avoid dropping valid large requests. Worth calling out where the parser gets wired in.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

if err == nil {
return nil
}
if (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) && lr.N <= 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] lr.N <= 0 can't reliably distinguish a genuine probe-limit overflow from a body whose length is exactly maxProbeBytes. If a malformed/truncated body is exactly maxProbeBytes long, the LimitedReader drains to N == 0 and the trailing EOF is misclassified as ErrProbeLimit rather than a parse error. The standard fix is to allocate one extra byte of budget — N: p.maxProbeBytes + 1 — and treat N == 0 here as proof the source actually exceeded the limit; a body of exactly maxProbeBytes then leaves N == 1 and is classified correctly. (Matches Codex's medium-severity finding.)

if err != nil {
return nil, false, classifyErr(err, lr)
}
return []string{method}, false, 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.

[nit] After the top-level object closes, Parse returns without checking for trailing non-whitespace data, so {"method":"eth_call"} garbage is accepted as valid. The downstream encoding/json handlers would reject such a body, so the parser is more lenient than the real decode path. The batch path validates the closing ] but likewise doesn't reject data after it. Consider asserting the decoder is at EOF (e.g. a final dec.Token() returning io.EOF) after the value, and add a test for trailing content. (Matches Codex's low-severity finding.)

@seidroid seidroid Bot dismissed their stale review July 14, 2026 11:10

Superseded: latest AI review found no blocking issues.

seidroid[bot]
seidroid Bot previously requested changes Jul 14, 2026

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

A well-structured, well-tested streaming JSON-RPC method extractor, but its case-sensitive "method" match diverges from the case-insensitive encoding/json decoding used downstream, which reintroduces the very rate-limit-bypass the duplicate-method check was added to prevent.

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

Blockers

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

Non-blocking

  • Cursor's second-opinion review (cursor-review.md) was empty — no findings from that pass.
  • Perf/behavior note (design tradeoff, not a bug): readMethodFromObject always drains each object to its closing } (to detect duplicate method) rather than returning as soon as method is found, so the PR description's 'returns as soon as method is found' overstates it — the in-code comment is accurate. A consequence is that a legitimate large body (e.g. eth_sendRawTransaction with >1 MiB calldata) yields ErrProbeLimit even when method appears first. Downstream wiring (out of scope here) must treat ErrProbeLimit as 'undetermined method' and fall back safely rather than dropping the request or applying the wrong limit; worth confirming the 1 MiB default is comfortably above expected max body sizes.
  • Consider mirroring encoding/json's actual duplicate resolution (last value wins) instead of rejecting, or document explicitly that rejection is intentionally stricter than the downstream decoder — either is defensible, but the two decoders' behaviors should be reconciled deliberately.

Comment thread ratelimiter/method_parser.go Outdated
// A valid JSON object always has string keys; anything else is malformed.
return "", ErrNotObject
}
if key == "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] Case-sensitive match reopens the bypass the duplicate-method check closes. Downstream JSON-RPC handlers (go-ethereum jsonrpcMessage, CometBFT RPCRequest) decode with encoding/json into Method string json:"method"``, which matches object keys case-insensitively and lets the last duplicate win. So:

  • {"method":"cheap","Method":"expensive"} → this parser returns "cheap" (the "Method" key is skipped, no duplicate detected), while the handler executes "expensive" — the request is charged as cheap but runs as expensive.
  • {"Method":"eth_call"} → this parser returns ErrNoMethod, but the handler still dispatches eth_call.

Since the whole point of reading each object to its end and rejecting duplicate method keys is to stay consistent with the downstream decoder and prevent this exact bypass, the comparison must be case-insensitive too: treat any case variant (strings.EqualFold(key, "method")) as the method field, and detect/reject duplicates across those variants. Please add mixed-case test cases ("Method", "METHOD", and the mixed-case duplicate above) to lock this in.

Comment thread ratelimiter/method_parser.go
Comment thread ratelimiter/method_parser.go
Comment thread ratelimiter/method_parser.go
Comment thread ratelimiter/method_parser.go

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

A self-contained, thoroughly tested streaming JSON-RPC method extractor with no request-path wiring; the code is correct and self-consistent, but a few of its own doc comments and the PR description oversell the allocation-avoidance and early-exit behavior.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • PR description inaccuracy: it states the parser "returns as soon as method is found" and that tests include "a byte-counting reader proving the parser stops reading once method is found early." Neither is true — readMethodFromObject deliberately reads each object to its end to detect duplicate method keys (documented at lines 15-17), so it does not stop early, and no such byte-counting test exists in method_parser_test.go. Update the description to match the implemented (and correct) read-to-end-for-dup-detection behavior. (Consolidates Codex P1 #1.)
  • Design consequence worth a note for the follow-up wiring PRs: because the whole object is read within the probe budget, a legitimate large request with method first (e.g. a >1 MiB eth_sendRawTransaction) returns ErrProbeLimit rather than the method name. The wiring middleware will need a defined fallback (full decode / reject) for ErrProbeLimit on otherwise-valid bodies.
  • cursor-review.md is empty — the Cursor second-opinion pass produced no output. REVIEW_GUIDELINES.md is also empty/missing, so no repo-specific review standards could be applied.
  • No prompt-injection or malicious content was found in the diff, tests, or PR description.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

// skipValue consumes exactly one complete JSON value from dec — the value whose
// first token dec is positioned at. Scalars cost one token; objects and arrays
// are drained with a depth counter so their (possibly large) contents are read
// off the stream but never decoded into Go structures.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Minor doc/impl mismatch (also flagged by Codex): dec.Token() on a scalar string value does materialize it as a Go string, so a large string inside params is allocated and scanned — only composite ({}/[]) contents are truly drained without building a Go value. The allocation is transient and bounded by maxProbeBytes, so impact is low, but consider softening the comment (and the PR's "params is never allocated" claim) to "never decoded into a full Go structure" to avoid overstating the guarantee.

@seidroid seidroid Bot dismissed their stale review July 14, 2026 11:57

Superseded: latest AI review found no blocking issues.

@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 inline nit finding (expectEOF conflating probe-limit exhaustion with a real EOF), this run also examined and ruled out three other candidate issues flagged by finder agents: an int64 overflow in maxProbeBytes+1 (only possible at math.MaxInt64, not a realistic probe size), batch parsing being all-or-nothing vs. downstream per-element dispatch (no request-path wiring exists yet in this PR, so there is no live divergence to compare against), and a JSON null method value enabling an admission bypass (dec.Token() returns an untyped nil for null, which fails the string type assertion and correctly yields ErrMethodNotString, not a silent accept).

Extended reasoning...

This is an informational note only, not a fresh review of the whole PR. The bug hunting system's single new finding this run (expectEOF's probe-limit/EOF ambiguity) is a nit per all four verifiers — fail-safe direction, no live consumer yet, small fix mirroring an existing pattern in the same file — and is already captured in the inline comment. Separately, finder agents raised four other candidate issues that verifiers refuted; three of those (int64 overflow, batch all-or-nothing semantics, JSON null bypass) are concrete enough to record here so they are not re-explored from scratch in a future pass. This does not change the overall assessment of the PR.

Comment thread ratelimiter/method_parser.go Outdated
Comment on lines +98 to +106
func expectEOF(dec *json.Decoder) error {
if _, err := dec.Token(); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
return ErrTrailingData
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 expectEOF treats any io.EOF from the final dec.Token() call as a clean end-of-input, but io.LimitedReader.Read also returns io.EOF once its probe budget is exhausted — regardless of whether the underlying reader still has bytes left. If trailing garbage sits beyond the probe budget, expectEOF misclassifies the budget-exhaustion EOF as "clean" and silently accepts a body it should reject (or should surface as ErrProbeLimit), unlike classifyErr which already guards on lr.N<=0 for this exact ambiguity.

Extended reasoning...

The bug. expectEOF (ratelimiter/method_parser.go:97-106) calls dec.Token() after the top-level value closes and treats any errors.Is(err, io.EOF) as proof the body ended cleanly. But the underlying reader is io.LimitedReader{N: maxProbeBytes+1}, and io.LimitedReader.Read returns io.EOF once N reaches 0 — a signal that means "probe budget exhausted," not "stream truly ended." classifyErr already treats this exact ambiguity carefully, checking lr.N <= 0 before mapping an EOF to ErrProbeLimit rather than a genuine parse success/failure. expectEOF has no equivalent guard, so it can accept a budget-exhausted EOF as a clean finish even when real trailing bytes exist past the cap.

Trigger path. If trailing non-whitespace garbage sits beyond the probe budget (i.e. the object + any intervening whitespace exactly exhausts lr.N), the decoder's Read call inside expectEOF's dec.Token() returns io.EOF purely because N hit 0 — not because the source reader is actually empty. expectEOF sees errors.Is(err, io.EOF) and returns nil, so Parse never re-enters classifyErr for this path and the request is reported as clean.

Step-by-step proof (verified independently by all four verifiers against the actual code, with the synthesis description's specific byte-count example found to be off by the exact +1 budget offset). Using body {"method":"a"} + two spaces + "X" (17 bytes total) and a small custom probe:

  1. NewMethodParser(15) sets lr.N = maxProbeBytes+1 = 16.
  2. json.Decoder buffers up to 16 bytes on its internal read — the 14-byte object plus the 2 trailing spaces — draining lr.N to 0. The final X is never read into the buffer.
  3. readMethodFromObject succeeds, returning method "a".
  4. expectEOF calls dec.Token(), which triggers another Read on the now-exhausted LimitedReader; that Read returns io.EOF because N<=0, not because the source (strings.Reader) is actually at its end — the X byte is still sitting there.
  5. expectEOF's errors.Is(err, io.EOF) branch fires and returns nil, so Parse returns methods=["a"], batch=false, err=nil — silently accepting a body whose trailing byte was never inspected.
  6. Compare: raising the probe by just enough that the whole 17-byte body fits (e.g. probe=17) makes expectEOF correctly see the real X token and return ErrTrailingData. The classification flips purely on whether the probe boundary lands before or after the garbage — exactly the ambiguity classifyErr's lr.N<=0 check exists to resolve, but which expectEOF bypasses entirely.

Why existing tests don't catch it. TestParse_TrailingData and TestParse_ExactProbeLimitBodyParses exercise trailing-data rejection and exact-probe-length bodies separately, but no test combines a small custom probe with trailing garbage landing exactly past the cap — the one condition where expectEOF's io.EOF branch is genuinely ambiguous.

Fix. Mirror classifyErr: pass lr into expectEOF and, on io.EOF, only treat it as clean if lr.N > 0; if lr.N <= 0, propagate the EOF so Parse's existing classifyErr(err, lr) call maps it to ErrProbeLimit instead of silently returning success.

Impact / severity. All four independent verifiers who reproduced this converged on nit: (1) the direction is fail-safe, not fail-open — the method reported ("a") is the same one a full downstream encoding/json decode would also see, and the full body (read again downstream, unbounded by any probe) would independently reject the trailing garbage as invalid JSON, so the request never dispatches; there is no rate-limit bypass, mis-charge, crash, or data loss. (2) The trigger requires a custom, small maxProbeBytes with garbage landing exactly at the probe boundary — at the 1 MiB default this needs a >1 MiB body with garbage precisely at that byte offset, an exotic scenario. (3) This PR wires no request path yet, so there is no live consumer today. It is nonetheless a genuine, verified inconsistency in validation logic this PR just added specifically to catch trailing data, and the fix is small and mirrors an existing pattern in the same file.

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

⚠️ Code review skipped — your organization has reached its monthly code review spending cap.

An organization admin can view or raise the cap at claude.ai/admin-settings/claude-code. The cap resets at the start of the next billing period.

Once the cap resets or is raised, push a new commit or reopen this pull request to trigger a 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.

Adds a self-contained, well-tested streaming JSON-RPC MethodParser with no request-path wiring. One narrow edge case (integer overflow when maxProbeBytes is set near math.MaxInt64) is worth guarding; otherwise the logic and test coverage are solid.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's second-opinion review (cursor-review.md) produced no output — treat it as absent, not as a clean pass.
  • The whole request object is read to its closing brace to detect duplicate method keys, so any legitimate body larger than maxProbeBytes also returns ErrProbeLimit. This is intentional and documented in the PR, but the future wiring middleware must treat ErrProbeLimit as "method undetermined → fall back to full decode" and never as a rejection; consider stating this contract in the ErrProbeLimit doc comment so downstream integrators don't misuse it.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

func (p *MethodParser) Parse(r io.Reader) (methods []string, batch bool, err error) {
// N is maxProbeBytes+1 so that lr.N reaching 0 unambiguously means the body
// exceeded the budget.
lr := &io.LimitedReader{R: r, N: p.maxProbeBytes + 1}

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] p.maxProbeBytes + 1 overflows to a negative value when maxProbeBytes is math.MaxInt64 (or within 1 of it). A negative LimitedReader.N makes every Read return io.EOF immediately, and classifyErr then maps that to ErrProbeLimit (because lr.N <= 0), so every Parse call fails. It requires an extreme configured value, but since NewMethodParser is the public entry point, consider capping/rejecting oversized values (e.g. clamp to math.MaxInt64 - 1 or reject in the constructor) so the sentinel-byte arithmetic can't wrap. (Flagged by Codex.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is outdated I think since you subtract 1 in the constructor

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

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 4a27fae. Configure here.

Comment thread ratelimiter/method_parser.go
@amir-deris amir-deris requested review from bdchatham and masih July 14, 2026 16:14

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

A well-scoped, self-contained addition of a streaming JSON-RPC MethodParser with thorough test coverage and no request-path wiring. No blocking issues; one design caveat (shared with Codex) should be carried into the follow-up wiring PR, plus a minor style nit.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • Design caveat (also raised by Codex, P1): because each object is read to its end to detect duplicate method keys, an attacker can pad a request with whitespace/benign fields past maxProbeBytes and force ErrProbeLimit even when method appears first. Per the PR description the wiring middleware must treat ErrProbeLimit as 'method undetermined → full decode'; if that fallback is unrestricted it becomes a per-method rate-limit bypass. This is out of scope for this parser-only PR, but the follow-up EVM/CometBFT middleware PRs must bound (or rate-limit conservatively on) the fallback path. Recommend adding an explicit test/assertion in those PRs. Not blocking here.
  • Cursor's second-opinion review file (cursor-review.md) was empty — that pass produced no output.
  • Minor: skipValue/dec.Token() fully materializes large string values in params (e.g. a ~200 KiB hex blob) into a Go string even though the value is skipped, so the 'avoid paying the memory cost of the whole request' goal is only partially met for a single oversized string field (still bounded by maxProbeBytes). Acceptable given the probe cap; noting for awareness.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

maxProbeBytes = DefaultMaxProbeBytes
}
if maxProbeBytes == math.MaxInt64 {
//preventing overflow error

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Nit: add a space after // (// preventing overflow error) to match the file's comment style, and consider rewording to explain why — the maxProbeBytes+1 sentinel in Parse would overflow to a negative LimitedReader.N if maxProbeBytes == math.MaxInt64.

@amir-deris amir-deris requested a review from sei-will July 14, 2026 16:14
@bdchatham

Copy link
Copy Markdown
Contributor

Ran a cross-review of this at head edde4ea across four lenses (security, systems, networking/RPC semantics, and Go idiom), reviewing independently before comparing notes. Summary up top, full ledger in the toggle.

The parser is in good shape. The security pass could not find a token-level report-one-method-dispatch-another differential. The case-fold match against encoding/json is correct, and rejecting duplicate method keys is fail-closed. The probe-budget invariant holds against decoder read-ahead, the code is reentrant, and skipValue is iterative so there's no deep-nesting blowup. Most of the earlier bot findings (duplicate-first, missing ] validation, the probe -1) are already fixed at this head.

Three things I'd like to close, filed as inline comments:

  1. The fail-closed contract that makes this safe lives only in the PR description. Worth writing into the package doc so 1b/1c build on it.
  2. skipValue boxes every scalar via Token(), which the comment says it avoids, measured around 600k allocs per within-budget request. A RawMessage skip fixes it.
  3. CometBFT serves methods in the URL path for GET requests, which the body parser can't see. Needs a scope decision before the :26657 wiring.

None of these block the parser as a standalone piece, they're mostly about the contract the wiring will inherit.

Full review ledger (boundaries, findings, what was verified sound)

Boundaries checked

Interface / Boundary Status Evidence
Method extraction vs downstream encoding/json (duplicate key) COMPATIBLE (fail-closed) :161-164 rejects dup; evmrpc/sei_legacy_http.go:91 decodes Method string last-wins. Reject can only over-reject, never report-A-run-B.
Case-insensitive "method" match COMPATIBLE strings.EqualFold (:161); all 6 letters of "method" are ASCII-fold-only.
ErrProbeLimit vs wiring "fall back to full decode" MISSING Safety depends on consumer error mapping; lives only in PR prose. → F1
CometBFT :26657 URI/GET (method in path) MISSING Parse reads body only (:64). → F3
Trailing-data rejection MISMATCH (fail-closed-safe) expectEOF (:100-108) stricter than go-eth HTTP, matches CometBFT.
Batch atomicity vs per-element dispatch MISMATCH (fail-closed-safe) readBatchMethods aborts whole batch (:116-127); dispatchers run valid elements.
Shared probe budget across batch MISMATCH (behavioral) One LimitedReader (:61); a padded early element can push a later method past budget.

Findings requiring action: F1 (doc the fail-closed contract), F2 (skipValue boxing / false comment, ~600k allocs, RawMessage fix), F3 (CometBFT method-in-path scope).

Deferrable (fail-closed-safe or wiring-time): ErrProbeLimit not returned when an over-budget body truncates into a SyntaxError; trailing-whitespace at exactly budget+1 flips a found method to a misleading ErrProbeLimit; tee must be independent of Parse on fallback; no batch item cap; test gaps (chunked reader, alloc-bound guard).

Verified sound: no report-A/run-B differential (exhaustive fold analysis); probe-budget invariant robust against read-ahead; concurrent-safe; iterative skipValue; null/number/wrong-type method, empty batch, top-level scalar all classify safely; reads as native Go (idiom pass ratified, advisory nits only).

Reviewed independently, blinded, at edde4ea.

// Parse reads a JSON-RPC request body from r and returns the method name(s) it
// carries. A single request object yields a one-element slice with batch=false;
// a top-level array yields one method per element in order with batch=true.
func (p *MethodParser) Parse(r io.Reader) (methods []string, batch bool, err error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The safety of this gate rests on how the caller handles these errors, and right now that contract lives only in the PR description. Every error other than ErrProbeLimit has to mean reject or fall back to a full decode, and ErrProbeLimit has to mean fall back rather than reject. If a caller ever maps a parse error to admit-with-default, the duplicate-key and trailing-data rejections stop being fail-closed, and a body that reports a cheap method while decoding to an expensive one gets through, which is the case this gate exists to catch.

There's nothing to fix at runtime in 1a since there's no wiring yet. I'd like the fail-closed contract written into the package doc so 1b and 1c build on it explicitly, something like every error other than ErrProbeLimit means reject and ErrProbeLimit means fall back to full decode. Worth carrying as an acceptance criterion into those PRs too.

return method, nil
}

// skipValue consumes exactly one complete JSON value from dec — the value whose

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment here says the skipped contents are read off the stream but never decoded into Go structures, and that isn't quite what happens. dec.Token() decodes every token, allocating a string for each string and boxing each scalar into an interface{}. Since we drain the whole object to catch a duplicate method, every request pays that on the rate-limiter path. A within-budget scalar-heavy params array measured around 600k allocations and 11 MB of garbage per request, which works against the streaming-partial-read goal.

The non-method branch can skip with a RawMessage decode instead, which still validates and still respects the LimitedReader budget but drops the per-scalar boxing (roughly 600k allocs down to 23 in the same benchmark):

var raw json.RawMessage
if err := dec.Decode(&raw); err != nil {
    return err
}

At a minimum I'd correct the comment, but the RawMessage swap looks cheap and worth doing here.

lr := &io.LimitedReader{R: r, N: p.maxProbeBytes + 1}
dec := json.NewDecoder(lr)

tok, err := dec.Token()

@bdchatham bdchatham Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One coverage gap to flag for the :26657 path. CometBFT serves every RPC as a URI route as well, so GET /broadcast_tx_sync?tx=... carries the method in the URL path with no method field in the body. This parser reads the body first, so those requests would come back as ErrNoMethod and the real method never gets classified.

Not necessarily something to solve in 1a. I'd like us to decide the scope explicitly, either this parser is only ever fed body-form requests and we say so in the doc, or 1c pulls the method from the path before falling back to the body parser. Either is fine, I just want it written down before the CometBFT wiring lands.

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.

3 participants