Replaces #559, which conflicted. Model: opus-5-5
This commit is contained in:
@@ -63,7 +63,7 @@ const (
|
||||
|
||||
// receiverAggregateMultiplier scales the configured
|
||||
// per-entrypoint receiver limit into the aggregate limit one
|
||||
// client IP may spend across the whole /webhook/* route. Ten
|
||||
// client IP may spend across the whole /h/* 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.
|
||||
@@ -123,9 +123,8 @@ func bucketKey(addr netip.Addr) string {
|
||||
return prefix.String()
|
||||
}
|
||||
|
||||
// isTrustedProxy reports whether addr belongs to a network the
|
||||
// operator listed in TRUSTED_PROXIES. The list is empty by default,
|
||||
// so by default nothing is trusted.
|
||||
// isTrustedProxy reports whether addr belongs to a network in
|
||||
// TRUSTED_PROXIES, which by default is the RFC 1918 private ranges.
|
||||
func (m *Middleware) isTrustedProxy(addr netip.Addr) bool {
|
||||
for _, prefix := range m.params.Config.TrustedProxies {
|
||||
if prefix.Contains(addr) {
|
||||
@@ -203,66 +202,87 @@ func (m *Middleware) forwardedClientAddr(
|
||||
}
|
||||
|
||||
// rateLimitKey is the client identity every rate limiter in this
|
||||
// package buckets on. Forwarded headers are honoured only when the
|
||||
// direct peer (RemoteAddr) is inside the configured trusted-proxy
|
||||
// set; otherwise the peer address itself is the key. Without that
|
||||
// gate any client could mint a fresh bucket per request, or starve
|
||||
// another client's bucket, by picking an X-Forwarded-For value —
|
||||
// which makes every limit here decorative against a deliberate
|
||||
// attacker.
|
||||
//
|
||||
// The address that identifies the client is then reduced to a bucket
|
||||
// by bucketKey: full address for IPv4, /64 prefix for IPv6.
|
||||
// package buckets on: the address clientAddr attributes the request
|
||||
// to, reduced to a bucket by bucketKey — full address for IPv4, /64
|
||||
// prefix for IPv6.
|
||||
func (m *Middleware) rateLimitKey(r *http.Request) (string, error) {
|
||||
return m.clientKey(r), nil
|
||||
}
|
||||
|
||||
// clientKey computes the bucket key described on rateLimitKey.
|
||||
func (m *Middleware) clientKey(r *http.Request) string {
|
||||
peer, err := netip.ParseAddr(ipFromHostPort(r.RemoteAddr))
|
||||
if err != nil {
|
||||
addr, ok := m.clientAddr(r)
|
||||
if !ok {
|
||||
// Not an address we can reason about; key on the raw
|
||||
// value, the most specific identity left. Distinct
|
||||
// RemoteAddr values stay in distinct buckets, so this
|
||||
// path cannot silently collapse unrelated clients
|
||||
// together. On a Unix-socket listener every peer
|
||||
// carries the same RemoteAddr and so shares one bucket,
|
||||
// which is the fail-closed direction.
|
||||
// which is the fail-closed direction. An empty RemoteAddr
|
||||
// is a different case, which net/http never produces for
|
||||
// a TCP listener and only a hand-built request carries,
|
||||
// but it fails closed the same way: every such request
|
||||
// shares the one bucket keyed on the empty string.
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
return bucketKey(addr)
|
||||
}
|
||||
|
||||
// clientAddr is the address a request is attributed to. The rate
|
||||
// limiters key on it and the logs name it as clientIP.
|
||||
//
|
||||
// Forwarded headers are honoured only when the direct peer
|
||||
// (RemoteAddr) is inside the configured trusted-proxy set; otherwise
|
||||
// the peer address itself is the client. Without that gate any client
|
||||
// could mint a fresh bucket per request, or starve another client's
|
||||
// bucket, by picking an X-Forwarded-For value — which makes every
|
||||
// limit here decorative against a deliberate attacker.
|
||||
//
|
||||
// ok is false when RemoteAddr is not an address at all.
|
||||
func (m *Middleware) clientAddr(r *http.Request) (netip.Addr, bool) {
|
||||
peer, err := netip.ParseAddr(ipFromHostPort(r.RemoteAddr))
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
peer = normalizeAddr(peer)
|
||||
if !m.isTrustedProxy(peer) {
|
||||
return bucketKey(peer)
|
||||
return peer, true
|
||||
}
|
||||
|
||||
if addr, ok := m.forwardedClientAddr(r); ok {
|
||||
return bucketKey(addr)
|
||||
return addr, true
|
||||
}
|
||||
|
||||
return bucketKey(peer)
|
||||
return peer, true
|
||||
}
|
||||
|
||||
// tooManyRequests returns the 429 handler used by the
|
||||
// password-change and per-entrypoint receiver limiters: it logs the
|
||||
// tooManyRequests returns the 429 handler used by the postRateLimit
|
||||
// limiters and the per-entrypoint receiver limiter: 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.
|
||||
//
|
||||
// The path is capped against the same budget as the access log's url
|
||||
// field. The per-entrypoint receiver limiter is unauthenticated and
|
||||
// its path is a client-chosen segment of client-chosen length, so at
|
||||
// WARN an uncapped path would let a sender pick the size of the line
|
||||
// it writes — the same defect the access log capping closed.
|
||||
// On ReceiverRoute the line names the route pattern, so the
|
||||
// entrypoint UUID in the path is never logged. Elsewhere it names the
|
||||
// path, capped against the same budget as the access log's url field;
|
||||
// the limiters there sit behind RequireAuth, so the cap is defensive.
|
||||
func (m *Middleware) tooManyRequests(
|
||||
logMessage, responseMessage string,
|
||||
) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if routePatternID(r.Context()) == ReceiverRoute {
|
||||
path = ReceiverRoute
|
||||
}
|
||||
|
||||
m.log.Warn(
|
||||
logMessage,
|
||||
"path", logfield.Truncate(
|
||||
r.URL.Path, logfield.MaxBytes,
|
||||
),
|
||||
"path", logfield.Truncate(path, logfield.MaxBytes),
|
||||
"remoteIP", RemoteIP(r),
|
||||
"clientIP", ClientIP(r),
|
||||
)
|
||||
http.Error(w, responseMessage, http.StatusTooManyRequests)
|
||||
}
|
||||
@@ -287,8 +307,12 @@ func (m *Middleware) tooManyRequests(
|
||||
func (m *Middleware) floodTooManyRequests(
|
||||
logMessage, responseMessage string,
|
||||
) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
m.log.Debug(logMessage)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
m.log.Debug(
|
||||
logMessage,
|
||||
"remoteIP", RemoteIP(r),
|
||||
"clientIP", ClientIP(r),
|
||||
)
|
||||
http.Error(w, responseMessage, http.StatusTooManyRequests)
|
||||
}
|
||||
}
|
||||
@@ -390,7 +414,7 @@ func (m *Middleware) postRateLimit(
|
||||
// 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
|
||||
// /h/{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
|
||||
|
||||
Reference in New Issue
Block a user