Bound the receiver rate limit per client IP across /webhook/* (closes #139)
All checks were successful
check / check (push) Successful in 3m11s
All checks were successful
check / check (push) Successful in 3m11s
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:
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
@@ -33,6 +34,14 @@ const (
|
||||
// requests per minute.
|
||||
receiverRateInterval = 1 * time.Minute
|
||||
|
||||
// receiverAggregateMultiplier scales the configured
|
||||
// per-entrypoint receiver limit into the aggregate limit one
|
||||
// client IP may spend across the whole /webhook/* route. Ten
|
||||
// entrypoints' worth lets a single sender address drive several
|
||||
// entrypoints at their full rate, while still capping what one
|
||||
// address costs the unauthenticated receiver.
|
||||
receiverAggregateMultiplier = 10
|
||||
|
||||
// maxForwardedHops bounds how many X-Forwarded-For entries the
|
||||
// chain walk examines. Real chains are one to three hops, but a
|
||||
// client can pad the header up to MaxHeaderBytes, so without a
|
||||
@@ -162,9 +171,11 @@ func (m *Middleware) clientKey(r *http.Request) string {
|
||||
return peer.String()
|
||||
}
|
||||
|
||||
// tooManyRequests returns the 429 handler shared by every limiter:
|
||||
// it logs the rejection with logMessage and answers with
|
||||
// responseMessage. httprate adds the Retry-After header (RFC 6585).
|
||||
// tooManyRequests returns the 429 handler used by the login,
|
||||
// password-change and per-entrypoint receiver limiters: it logs the
|
||||
// rejection with logMessage and answers with responseMessage.
|
||||
// httprate adds the Retry-After header (RFC 6585). The aggregate
|
||||
// receiver limiter uses floodTooManyRequests instead.
|
||||
func (m *Middleware) tooManyRequests(
|
||||
logMessage, responseMessage string,
|
||||
) http.HandlerFunc {
|
||||
@@ -174,6 +185,31 @@ func (m *Middleware) tooManyRequests(
|
||||
}
|
||||
}
|
||||
|
||||
// floodTooManyRequests returns the 429 handler for a limiter whose
|
||||
// rejections are themselves the flood: it logs at DEBUG and without
|
||||
// the path, then answers with responseMessage.
|
||||
//
|
||||
// The aggregate receiver limiter trips exactly when one address is
|
||||
// sending faster than the receiver wants to serve, so its rejection
|
||||
// log is one line per request of that flood. At WARN with "path" that
|
||||
// hands a client a way to write its own text into the operator's log,
|
||||
// at a level that trips alerting, once per request — the log-volume
|
||||
// problem this limiter exists to bound. DEBUG is off in production by
|
||||
// default, so a flood costs nothing here; the path is dropped so that
|
||||
// turning DEBUG on to diagnose one does not restore the problem.
|
||||
//
|
||||
// This limiter bounds the database work an invented path costs, not
|
||||
// the number of log lines it produces: the access log in
|
||||
// middleware.go still records every request, served or rejected.
|
||||
func (m *Middleware) floodTooManyRequests(
|
||||
logMessage, responseMessage string,
|
||||
) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
m.log.Debug(logMessage)
|
||||
http.Error(w, responseMessage, http.StatusTooManyRequests)
|
||||
}
|
||||
}
|
||||
|
||||
// LoginRateLimit returns middleware that enforces per-IP rate
|
||||
// limiting on login attempts using go-chi/httprate. Only POST
|
||||
// requests are rate-limited; GET requests (rendering the login
|
||||
@@ -242,15 +278,26 @@ func (m *Middleware) postRateLimit(
|
||||
}
|
||||
}
|
||||
|
||||
// ReceiverRateLimit returns middleware that rate-limits the
|
||||
// public webhook receiver endpoint per client IP per request
|
||||
// path (the path contains the entrypoint UUID, so each sender
|
||||
// is limited per entrypoint without affecting other senders or
|
||||
// other entrypoints). The limit is Config.ReceiverRateLimit
|
||||
// requests per minute. Requests over the limit receive a 429.
|
||||
// Clients are identified by rateLimitKey.
|
||||
// ReceiverRateLimit returns middleware that rate-limits the public
|
||||
// webhook receiver endpoint with two limits in series.
|
||||
//
|
||||
// The inner limit is per client IP per request path: the path
|
||||
// contains the entrypoint UUID, so each sender is limited per
|
||||
// entrypoint without affecting other senders or other entrypoints.
|
||||
// It is Config.ReceiverRateLimit requests per minute.
|
||||
//
|
||||
// That limit alone bounds nothing in aggregate. The route pattern
|
||||
// /webhook/{uuid} matches any single segment, so a client that
|
||||
// invents a fresh path per request mints a fresh bucket per request
|
||||
// and never refills one — and every such request still reaches the
|
||||
// handler's entrypoint lookup before it 404s. The outer limit is
|
||||
// therefore keyed on the client IP alone, capping what one address
|
||||
// can spend across the whole route however it varies the path.
|
||||
//
|
||||
// Requests over either limit receive a 429. Clients are identified
|
||||
// by rateLimitKey.
|
||||
func (m *Middleware) ReceiverRateLimit() func(http.Handler) http.Handler {
|
||||
return httprate.Limit(
|
||||
perEntrypoint := httprate.Limit(
|
||||
m.params.Config.ReceiverRateLimit,
|
||||
receiverRateInterval,
|
||||
httprate.WithKeyFuncs(
|
||||
@@ -262,4 +309,31 @@ func (m *Middleware) ReceiverRateLimit() func(http.Handler) http.Handler {
|
||||
"Too many requests. Please slow down.",
|
||||
)),
|
||||
)
|
||||
|
||||
aggregate := httprate.Limit(
|
||||
receiverAggregateLimit(m.params.Config.ReceiverRateLimit),
|
||||
receiverRateInterval,
|
||||
httprate.WithKeyFuncs(m.rateLimitKey),
|
||||
httprate.WithLimitHandler(m.floodTooManyRequests(
|
||||
"webhook receiver aggregate rate limit exceeded",
|
||||
"Too many requests. Please slow down.",
|
||||
)),
|
||||
)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return aggregate(perEntrypoint(next))
|
||||
}
|
||||
}
|
||||
|
||||
// receiverAggregateLimit is the per-IP aggregate limit derived from
|
||||
// the configured per-entrypoint limit. The operator sets the latter
|
||||
// and nothing bounds it from above, so the multiplication is
|
||||
// saturated rather than allowed to wrap into a negative limit that
|
||||
// would reject every request.
|
||||
func receiverAggregateLimit(perEntrypoint int) int {
|
||||
if perEntrypoint > math.MaxInt/receiverAggregateMultiplier {
|
||||
return math.MaxInt
|
||||
}
|
||||
|
||||
return perEntrypoint * receiverAggregateMultiplier
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user