Bound the receiver rate limit per client IP across /webhook/* (closes #139)
All checks were successful
check / check (push) Successful in 3m35s

The receiver limiter keyed buckets on (client IP, request path). The
route pattern /webhook/{uuid} matches any single segment, so a client
that invented a fresh path per request minted a fresh bucket per
request and never refilled one: its aggregate rate against the only
unauthenticated, internet-exposed endpoint was unbounded, and every
one of those requests reached an entrypoint lookup before it 404ed.

Put a second limiter in front of it, keyed on the client IP alone and
covering the whole route at ten times the configured per-entrypoint
limit (1200/min by default). The per-entrypoint limit is unchanged and
still wanted; it just bounds nothing in aggregate on its own. Ten
entrypoints' worth of headroom lets one sender address drive several
entrypoints at full rate while still capping what one address costs
the receiver. The multiplication saturates rather than wrapping, since
nothing bounds RECEIVER_RATE_LIMIT from above and a negative limit
would reject every request.

The aggregate limiter is the outer one, so it counts the requests the
per-entrypoint limiter rejects; a test pins that order by exhausting
one path against the inner limit and then requiring a request to an
unused path to be rejected.

Move the handler's INFO line for an incoming webhook below the
entrypoint lookup. The UUID is attacker-controlled path text, so
logging it first let a client write an INFO line per invented path; a
miss is already logged at DEBUG and the request is already in the
access log.

Give the aggregate limiter its own 429 handler that logs at DEBUG and
without the path, rather than the shared one that logs at WARN with
it. Its rejections are one line per request of the very flood it
exists to bound, so the shared handler would have let a client write
its own text into the operator's log at an alerting level once per
request. What this limiter bounds is the database work an invented
path costs; the access log still records every request once at INFO,
and the README now says so instead of claiming DEBUG-only logging.
This commit is contained in:
2026-08-12 10:37:58 +00:00
parent 3941f0b0ff
commit ae74852ea2
5 changed files with 255 additions and 16 deletions

View File

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