Skip to content
9 changes: 9 additions & 0 deletions evmrpc/rate_limit_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ 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) {
// 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
}
Comment thread
cursor[bot] marked this conversation as resolved.
m.rejectAdmission(r.Context(), w, ip, rejectReasonReadError, http.StatusBadRequest, "bad request")
return
}
Expand Down
59 changes: 59 additions & 0 deletions evmrpc/rate_limit_middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -504,3 +504,62 @@ 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 := newTestRateLimitGate(t, reg, maxBody)
inner := blockUntilRelease(release, func() { admitted <- struct{}{} })
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)
}
4 changes: 3 additions & 1 deletion evmrpc/request_limiter.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package evmrpc

import (
"errors"
"fmt"
"io"
"net"
"net/http"
Expand Down Expand Up @@ -162,7 +163,7 @@ func (b *budgetBody) Read(p []byte) (int, error) {
}
if isReadIdleTimeout(err) {
b.fail(rejectReasonSlowBody, http.StatusRequestTimeout, "request timeout", err)
return n, err
return n, fmt.Errorf("%w: %w", errSlowBody, err)
}
b.release()
}
Expand Down Expand Up @@ -311,6 +312,7 @@ func (b *budgetBody) setOutcome(reason string, status int, message string) {
}

var errBudgetExhausted = errors.New("request byte budget exhausted")
var errSlowBody = errors.New("request body read idle timeout")

func isReadIdleTimeout(err error) bool {
if errors.Is(err, os.ErrDeadlineExceeded) {
Expand Down
Loading