Bound the receiver rate limit per client IP across /webhook/* (closes #139)
All checks were successful
check / check (push) Superseded by a newer commit; never tested
All checks were successful
check / check (push) Superseded by a newer commit; never tested
The receiver limiter keyed on the request path, and /webhook/{uuid} matches any single segment, so a client minted a fresh bucket per invented path and had unlimited aggregate rate against the only unauthenticated endpoint. An outer limiter keyed on the client address alone now bounds that, chained in front of the unchanged per-entrypoint limiter. Its rejections log at DEBUG without the path, and the README states what each limit does and does not bound.
This commit was merged in pull request #143.
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
@@ -670,6 +671,129 @@ func TestRateLimitKey_LongChainAllocationIsBounded(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths is the
|
||||
// regression test for the per-path bucket key. The route pattern
|
||||
// matches any single segment, so a client that never reuses a path
|
||||
// never reuses a per-entrypoint bucket either, and its aggregate
|
||||
// rate against the receiver is whatever it likes — with every
|
||||
// request reaching an entrypoint lookup before it 404s. The IP-only
|
||||
// aggregate limiter is what bounds that, so this must fail if the
|
||||
// aggregate limiter is removed.
|
||||
func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
limit = 3
|
||||
ip = "6.6.6.6:1234"
|
||||
)
|
||||
|
||||
aggregate := limit * middleware.ReceiverAggregateMultiplierConst
|
||||
|
||||
handler := receiverLimitedHandler(t, limit)
|
||||
|
||||
// Every request goes to a path this client has never used, so
|
||||
// none of them shares a per-entrypoint bucket with another.
|
||||
for i := range aggregate {
|
||||
w := receiverPost(
|
||||
handler, ip, fmt.Sprintf("/webhook/invented-%d", i),
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
"request %d to a distinct path should pass", i,
|
||||
)
|
||||
}
|
||||
|
||||
w := receiverPost(
|
||||
handler, ip, fmt.Sprintf("/webhook/invented-%d", aggregate),
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusTooManyRequests, w.Code,
|
||||
"a client must not be able to raise its aggregate rate "+
|
||||
"against /webhook/* by varying the path",
|
||||
)
|
||||
|
||||
// The aggregate limit is still per client IP: exhausting one
|
||||
// address must not throttle another.
|
||||
w = receiverPost(handler, "6.6.6.7:1234", "/webhook/invented-0")
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
"a different client IP must not be affected",
|
||||
)
|
||||
}
|
||||
|
||||
// TestReceiverRateLimit_RejectedRequestsCountTowardAggregate pins the
|
||||
// order the two limiters are chained in. The aggregate limiter has to
|
||||
// be the outer one, so that it counts requests the per-entrypoint
|
||||
// limiter rejects: those requests still arrive, and the aggregate
|
||||
// limit exists to bound what one address can make the receiver do.
|
||||
//
|
||||
// One path is hammered past the per-entrypoint limit, which alone
|
||||
// would leave the aggregate budget almost untouched; then a path the
|
||||
// client has never used must be rejected, which only the aggregate
|
||||
// limiter can do. Swap the two limiters and that last request is
|
||||
// served, because the rejected ones never reached the aggregate
|
||||
// limiter to be counted.
|
||||
func TestReceiverRateLimit_RejectedRequestsCountTowardAggregate(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
limit = 3
|
||||
ip = "6.6.6.8:1234"
|
||||
)
|
||||
|
||||
aggregate := limit * middleware.ReceiverAggregateMultiplierConst
|
||||
|
||||
handler := receiverLimitedHandler(t, limit)
|
||||
|
||||
// Spend the whole aggregate budget on one path. Only the first
|
||||
// limit requests are served; the rest are rejected by the
|
||||
// per-entrypoint limiter but still count against the aggregate.
|
||||
for i := range aggregate {
|
||||
w := receiverPost(handler, ip, "/webhook/exhausted")
|
||||
|
||||
want := http.StatusTooManyRequests
|
||||
if i < limit {
|
||||
want = http.StatusOK
|
||||
}
|
||||
|
||||
assert.Equal(
|
||||
t, want, w.Code,
|
||||
"request %d to the exhausted path", i,
|
||||
)
|
||||
}
|
||||
|
||||
w := receiverPost(handler, ip, "/webhook/never-used")
|
||||
assert.Equal(
|
||||
t, http.StatusTooManyRequests, w.Code,
|
||||
"requests rejected per entrypoint must still count "+
|
||||
"toward the aggregate limit, so the aggregate "+
|
||||
"limiter has to run first",
|
||||
)
|
||||
}
|
||||
|
||||
// TestReceiverAggregateLimit_SaturatesOnOverflow covers the derived
|
||||
// aggregate limit for a configured per-entrypoint limit large enough
|
||||
// that multiplying it would wrap negative, which httprate would read
|
||||
// as a limit that rejects every request.
|
||||
func TestReceiverAggregateLimit_SaturatesOnOverflow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t, 1200,
|
||||
middleware.ReceiverAggregateLimitForTest(120),
|
||||
"the default limit scales by the multiplier",
|
||||
)
|
||||
assert.Equal(
|
||||
t, math.MaxInt,
|
||||
middleware.ReceiverAggregateLimitForTest(math.MaxInt),
|
||||
"an overflowing limit saturates instead of wrapping",
|
||||
)
|
||||
}
|
||||
|
||||
// TestReceiverRateLimit_IgnoresForwardedFromUntrustedPeer proves
|
||||
// the receiver limiter uses the same gated key function as the
|
||||
// POST limiters.
|
||||
|
||||
Reference in New Issue
Block a user