Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions evmrpc/rate_limit_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ func (m *rateLimitMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request)
m.rejectAdmission(r.Context(), w, ip, rejectReasonOversize, http.StatusRequestEntityTooLarge, "request body too large")
return
}
if errors.Is(err, errBudgetExhausted) || isReadIdleTimeout(err) {
Comment thread
amir-deris marked this conversation as resolved.
Outdated
// The outer requestSizeLimiter already recorded the rejection reason
// (budget_midread/slow_body) and owns the response for this failure;
// charging admission here would debit an innocent client's per-IP
// bucket for a server-side capacity event and double-count the metric.
return
}
Comment thread
cursor[bot] marked this conversation as resolved.
m.rejectAdmission(r.Context(), w, ip, rejectReasonReadError, http.StatusBadRequest, "bad request")
return
}
Comment on lines 36 to 51

@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

Expand Down
63 changes: 63 additions & 0 deletions evmrpc/rate_limit_middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -544,3 +544,66 @@ func TestReadBoundedBody_RejectsOversize(t *testing.T) {
require.Equal(t, int64(len(body)), tracked.drained)
})
}

// TestComposedStack_BudgetMidreadDoesNotChargeInnocentIP guards against the
// mid-read global-budget exhaustion of one client charging a different,
// well-behaved client's per-IP bucket.
func TestComposedStack_BudgetMidreadDoesNotChargeInnocentIP(t *testing.T) {
const maxBody = 1000
const budget = 1500 // room for exactly one max-size body at a time

release := make(chan struct{})
admitted := make(chan struct{}, 1)

reg := mustRateLimitRegistry(t, 0.001, 1) // burst=1: any charge exhausts the bucket
gate := NewRateLimitGate(reg, maxBody, true, "evm")
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Comment thread
seidroid[bot] marked this conversation as resolved.
Outdated
Comment thread
amir-deris marked this conversation as resolved.
Outdated
admitted <- struct{}{}
<-release
w.WriteHeader(http.StatusOK)
})
stack := newRequestSizeLimiter(newRateLimitMiddleware(inner, gate), maxBody, budget, 0)

// A well-formed JSON-RPC body padded to exactly maxBody bytes: it must parse
// cleanly so the request reaches the byte-budget accounting inside
// readBoundedBody rather than getting rejected earlier as unparseable.
const prefix = `{"jsonrpc":"2.0","id":1,"method":"eth_call","params":["`
const suffix = `"]}`
fullSizeBody := prefix + strings.Repeat("x", maxBody-len(prefix)-len(suffix)) + suffix
require.Len(t, fullSizeBody, maxBody)

holderIP := "203.0.113.10:1"
victimIP := "203.0.113.20:1"

// The holder's request is admitted and reserves the whole budget, then blocks
// with its handler in flight so the reservation is not released yet.
firstDone := make(chan int, 1)
go func() {
rec := httptest.NewRecorder()
holderReq := newSizedRequest(fullSizeBody, maxBody)
holderReq.RemoteAddr = holderIP
stack.ServeHTTP(rec, holderReq)
firstDone <- rec.Code
}()
<-admitted

// The victim is a distinct, well-behaved client whose own request fails
// mid-read purely because the shared budget the holder reserved is gone.
rec := httptest.NewRecorder()
req := newSizedRequest(fullSizeBody, maxBody)
req.RemoteAddr = victimIP
stack.ServeHTTP(rec, req)
require.Equal(t, http.StatusTooManyRequests, rec.Code)
require.Contains(t, rec.Body.String(), "server busy")

close(release)
require.Equal(t, http.StatusOK, <-firstDone)

// The victim's own per-IP bucket must still be untouched: with burst=1, a
// request from that IP with room in the (now-released) budget still succeeds.
rec3 := httptest.NewRecorder()
req3 := newSizedRequest(`{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[]}`, -1)
req3.RemoteAddr = victimIP
stack.ServeHTTP(rec3, req3)
require.Equal(t, http.StatusOK, rec3.Code)
}
Loading