Skip to content

fix(evmrpc): don't charge an innocent client's per-IP bucket on mid-read budget exhaustion - #3935

Open
amir-deris wants to merge 6 commits into
mainfrom
amir/plt-780-address-ottersec-feedback
Open

fix(evmrpc): don't charge an innocent client's per-IP bucket on mid-read budget exhaustion#3935
amir-deris wants to merge 6 commits into
mainfrom
amir/plt-780-address-ottersec-feedback

Conversation

@amir-deris

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

Copy link
Copy Markdown
Contributor

Describe your changes and provide context

rateLimitMiddleware and the outer requestSizeLimiter both watch the same request body, but disagreed about who owns a mid-read failure. When the global byte budget (max_concurrent_request_bytes) or the body-read idle timeout was exhausted while readBoundedBody was reading, the error surfaced to rateLimitMiddleware as a generic read failure. It responded by charging the requesting client's own per-IP token bucket (chargeAdmissionRejection) and recording a read_error rejection metric — on top of the budget_midread/slow_body reason and metric the outer requestSizeLimiter had already recorded for the same request.

Two problems followed:

  1. A well-behaved client could be pushed toward rate-limiting for a purely server-side capacity event it didn't cause. Under load or an actual DoS that exhausts the shared global budget, an innocent client whose request happens to be mid-read at that moment gets its own bucket debited for someone else's traffic.
  2. evmrpc_requests_rejected_total double-counted the same rejected request under two different reasons.

Flagged in review on #3836: #3836 (comment)

Fix

rateLimitMiddleware now recognizes errBudgetExhausted / a read-idle-timeout error from readBoundedBody and returns without charging admission or recording its own rejection reason, deferring entirely to requestSizeLimiter, which already owns the correct reason and the client-visible response.

Testing performed to validate your change

  • Added TestComposedStack_BudgetMidreadDoesNotChargeInnocentIP, which holds the global budget open with one client's in-flight request and verifies a second, distinct client's request that fails mid-read due to budget exhaustion does not consume that client's own per-IP token (a follow-up request from the same IP still succeeds under burst=1).
  • Verified the new test fails without the fix and passes with it.
  • go test ./evmrpc/... passes.
  • gofmt -s / goimports clean on changed files.

@amir-deris amir-deris self-assigned this Aug 17, 2026
@amir-deris amir-deris changed the title Fixed bug related to charging tocken for innocent client fix(evmrpc): don't charge an innocent client's per-IP bucket on mid-read budget exhaustion Aug 17, 2026
@github-actions

github-actions Bot commented Aug 17, 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 17, 2026, 5:05 PM

@amir-deris
amir-deris marked this pull request as ready for review August 17, 2026 12:34
@cursor

cursor Bot commented Aug 17, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches admission control and per-IP rate limiting on error paths where middleware layers interact; behavior change is narrow but affects who gets rate-limited under capacity pressure.

Overview
Fixes double handling when readBoundedBody fails because the outer requestSizeLimiter already rejected the body (global byte budget or read-idle timeout). Those errors were treated as generic read failures in rateLimitMiddleware, which debited the requesting IP's token bucket and logged a read_error rejection on top of the limiter's budget_midread / slow_body response and metrics.

rateLimitMiddleware now branches on errBudgetExhausted and returns without charging admission or writing another response—the size limiter keeps ownership. For errSlowBody (new sentinel wrapped around idle-timeout read errors in budgetBody.Read), it still charges the client's per-IP bucket but avoids the extra rejectAdmission path.

Adds TestComposedStack_BudgetMidreadDoesNotChargeInnocentIP: one client holds the global budget while a second IP fails mid-read with 429; the victim's follow-up request still succeeds under burst=1.

Reviewed by Cursor Bugbot for commit 2ad70de. 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 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.

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1c57165. Configure here.

Comment thread evmrpc/rate_limit_middleware.go
@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 42.85714% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.51%. Comparing base (f0faa48) to head (2ad70de).

Files with missing lines Patch % Lines
evmrpc/rate_limit_middleware.go 33.33% 4 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3935      +/-   ##
==========================================
- Coverage   59.50%   58.51%   -1.00%     
==========================================
  Files        2326     2230      -96     
  Lines      198890   188338   -10552     
==========================================
- Hits       118359   110208    -8151     
+ Misses      69285    67727    -1558     
+ Partials    11246    10403     -843     
Flag Coverage Δ
sei-chain-pr 71.95% <50.00%> (?)
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/request_limiter.go 91.44% <100.00%> (+2.95%) ⬆️
evmrpc/rate_limit_middleware.go 89.77% <33.33%> (+0.54%) ⬆️

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

@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 fix correctly stops rateLimitMiddleware from debiting an innocent client's per-IP bucket when the shared byte budget is exhausted mid-read, and the new composed-stack test genuinely fails without it. One gap: the deferral is keyed on error shape rather than on the outer limiter actually owning the failure, so with both max_concurrent_request_bytes and body_read_idle_timeout set to 0 a ReadTimeout now returns silently instead of producing a status and a metric.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • The PR's second stated goal — no more double-counting of evmrpc_requests_rejected_total under two reasons — isn't covered by a test. TestRateLimitMiddleware_ParseErrorRecordsRejectedMetric already shows the sdkmetric.NewManualReader pattern for this package; asserting exactly one datapoint with budget_midread (and no read_error) for the mid-read failure would pin the metric half of the fix, which is otherwise only pinned by the status code.
  • Consider recording the new cross-layer rule in evmrpc/AGENTS.md's middleware-order section: requestSizeLimiter owns the response and rejection reason for budget_midread/slow_body, and rateLimitMiddleware must not charge admission for those. That ownership split is now load-bearing for two files and isn't derivable from either one alone.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/rate_limit_middleware.go Outdated
Comment thread evmrpc/rate_limit_middleware_test.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.

The errSlowBody sentinel closes the gap from the previous review — the deferral now keys on ownership (only budgetBody can produce either sentinel) rather than error shape, so the "both guards disabled" silent-200 path is gone, and the new composed-stack test genuinely pins the innocent-IP fix. Remaining notes are non-blocking: bundling slow_body (client-attributable) with budget_midread (server-attributable) also drops the fail-closed admission charge for slowloris clients, and neither the metric de-duplication nor the errSlowBody path itself is covered by a test.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • The PR's second stated goal — no more double-counting of evmrpc_requests_rejected_total under two reasons — still isn't covered by a test. TestRateLimitMiddleware_ParseErrorRecordsRejectedMetric already shows the sdkmetric.NewManualReader pattern in this package; asserting exactly one datapoint with budget_midread and none with read_error for the mid-read failure would pin the metric half of the fix, which is otherwise only pinned indirectly by the status code.
  • Only the errBudgetExhausted branch is exercised. errSlowBody is a new value flowing through a second layer, and nothing asserts that a body-read idle timeout now yields 408 + exactly one slow_body datapoint with the per-IP bucket untouched. TestRequestSizeLimiter_bodyReadIdleTimeout covers the limiter alone, not the composed stack.
  • Consider recording the cross-layer rule in evmrpc/AGENTS.md's middleware-order section (which already describes the 408/429 split at lines 19-24): requestSizeLimiter owns the response and rejection reason for budget_midread/slow_body, and rateLimitMiddleware must not charge admission or record a reason for those. The sentinels make it enforceable in code, but the ownership split spans two files and isn't derivable from either alone.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread evmrpc/rate_limit_middleware.go Outdated
Comment thread evmrpc/request_limiter.go Outdated
Comment thread evmrpc/rate_limit_middleware_test.go Outdated
@amir-deris

Copy link
Copy Markdown
Contributor Author

@seidroid review

Comment on lines 36 to 51
if isRequestBodyTooLarge(err) {
m.rejectAdmission(r.Context(), w, ip, rejectReasonOversize, http.StatusRequestEntityTooLarge, "request body too large")
return
}
if errors.Is(err, errBudgetExhausted) {
// Server-side capacity event; outer limiter already owns the response.
return
}
if errors.Is(err, errSlowBody) {
// Client-caused stall: still charge the per-IP bucket.
_ = m.gate.chargeAdmissionRejection(r.Context(), ip)
return
}
m.rejectAdmission(r.Context(), w, ip, rejectReasonReadError, http.StatusBadRequest, "bad request")
return
}

@bdchatham bdchatham Aug 17, 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.

Outside the scope of your PR, but a bit strange that we seem to be swallowing errors and not logging them.

@amir-deris amir-deris Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for feedback. Regarding logging, I think that opens the gate for more DOS problems due to writing logs to disk. It seems adding metrics around them could be a more suitable approach. Perhaps we can revisit this once it is rolled out and we see how much rate limiting traffic we get

Comment thread evmrpc/rate_limit_middleware.go Outdated
@amir-deris

Copy link
Copy Markdown
Contributor Author

@seidroid review

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