All checks were successful
check / check (push) Successful in 3m51s
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.
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.
313 lines
10 KiB
Go
313 lines
10 KiB
Go
package middleware
|
|
|
|
import (
|
|
"math"
|
|
"net/http"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/httprate"
|
|
)
|
|
|
|
const (
|
|
// loginRateLimit is the maximum number of login attempts
|
|
// per interval.
|
|
loginRateLimit = 5
|
|
|
|
// loginRateInterval is the time window for the rate limit.
|
|
loginRateInterval = 1 * time.Minute
|
|
|
|
// passwordChangeRateLimit is the maximum number of password
|
|
// change attempts per interval. Each attempt verifies the
|
|
// current password, so the endpoint must be rate-limited
|
|
// like any other password-based authentication endpoint.
|
|
passwordChangeRateLimit = 5
|
|
|
|
// passwordChangeRateInterval is the time window for the
|
|
// password change rate limit.
|
|
passwordChangeRateInterval = 1 * time.Minute
|
|
|
|
// receiverRateInterval is the time window for the webhook
|
|
// receiver rate limit. The configured limit is expressed in
|
|
// 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
|
|
// bound every request pays a walk proportional to whatever the
|
|
// client sent.
|
|
maxForwardedHops = 64
|
|
)
|
|
|
|
// normalizeAddr strips the IPv4-in-IPv6 wrapper and any zone from
|
|
// addr so that comparisons and bucket keys are canonical.
|
|
func normalizeAddr(addr netip.Addr) netip.Addr {
|
|
return addr.Unmap().WithZone("")
|
|
}
|
|
|
|
// 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.
|
|
func (m *Middleware) isTrustedProxy(addr netip.Addr) bool {
|
|
for _, prefix := range m.params.Config.TrustedProxies {
|
|
if prefix.Contains(addr) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// forwardedClientAddr returns the client address named by this
|
|
// request's X-Forwarded-For chain. It is consulted only for requests
|
|
// whose direct peer is a trusted proxy.
|
|
//
|
|
// X-Forwarded-For is the only header read. X-Real-IP and
|
|
// True-Client-IP are deliberately ignored: the reverse proxies in
|
|
// common use append to X-Forwarded-For and pass any other header the
|
|
// client sent through untouched, so believing a single-valued header
|
|
// would let a client behind the trusted proxy name its own bucket —
|
|
// the very bypass this gating exists to close.
|
|
//
|
|
// The chain is walked right to left, because the rightmost entry is
|
|
// the one the nearest proxy appended and everything to its left may
|
|
// have been written by the client. The first hop that is not itself
|
|
// a trusted proxy is the client. A hop that cannot be read as a bare
|
|
// address ends the walk: past it the chain is not the shape assumed
|
|
// here, so the caller falls back to the peer address.
|
|
//
|
|
// Only the last maxForwardedHops entries are examined. A longer chain
|
|
// is padding, and running out of hops falls back to the peer address
|
|
// the same way an unreadable hop does.
|
|
//
|
|
// The entries are cut off the right end of each header value in place
|
|
// rather than split out of it: the receiver is unauthenticated and a
|
|
// client can pad the header up to MaxHeaderBytes, so splitting would
|
|
// allocate in proportion to the padding (about 8 MB for a 1 MB
|
|
// header) before the cap could discard any of it. Multiple header
|
|
// values are walked in reverse for the same reason, since joining
|
|
// them copies the whole chain.
|
|
func (m *Middleware) forwardedClientAddr(
|
|
r *http.Request,
|
|
) (netip.Addr, bool) {
|
|
seen := 0
|
|
|
|
for _, value := range slices.Backward(
|
|
r.Header.Values("X-Forwarded-For"),
|
|
) {
|
|
for last := false; !last && seen < maxForwardedHops; seen++ {
|
|
hop := value
|
|
|
|
comma := strings.LastIndexByte(value, ',')
|
|
if comma < 0 {
|
|
last = true
|
|
} else {
|
|
hop, value = value[comma+1:], value[:comma]
|
|
}
|
|
|
|
hop = strings.TrimSpace(hop)
|
|
if hop == "" {
|
|
continue
|
|
}
|
|
|
|
addr, err := netip.ParseAddr(hop)
|
|
if err != nil {
|
|
return netip.Addr{}, false
|
|
}
|
|
|
|
if addr = normalizeAddr(addr); !m.isTrustedProxy(addr) {
|
|
return addr, true
|
|
}
|
|
}
|
|
}
|
|
|
|
return netip.Addr{}, false
|
|
}
|
|
|
|
// 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.
|
|
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 {
|
|
// Not an address we can reason about; key on the raw
|
|
// value, the most specific identity left. On a
|
|
// Unix-socket listener every peer carries the same
|
|
// RemoteAddr and so shares one bucket, which is the
|
|
// fail-closed direction.
|
|
return r.RemoteAddr
|
|
}
|
|
|
|
peer = normalizeAddr(peer)
|
|
if !m.isTrustedProxy(peer) {
|
|
return peer.String()
|
|
}
|
|
|
|
if addr, ok := m.forwardedClientAddr(r); ok {
|
|
return addr.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).
|
|
func (m *Middleware) tooManyRequests(
|
|
logMessage, responseMessage string,
|
|
) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
m.log.Warn(logMessage, "path", r.URL.Path)
|
|
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
|
|
// form) pass through unaffected. When the rate limit is exceeded,
|
|
// a 429 Too Many Requests response is returned. Clients are
|
|
// identified by rateLimitKey.
|
|
func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
|
|
return m.postRateLimit(
|
|
loginRateLimit,
|
|
loginRateInterval,
|
|
"login rate limit exceeded",
|
|
"Too many login attempts. Please try again later.",
|
|
)
|
|
}
|
|
|
|
// PasswordChangeRateLimit returns middleware that enforces
|
|
// per-IP rate limiting on password change attempts. The change
|
|
// endpoint verifies the current password, so without a limit a
|
|
// stolen session could be used to brute-force it; the limit
|
|
// matches the login endpoint's.
|
|
func (m *Middleware) PasswordChangeRateLimit() func(http.Handler) http.Handler {
|
|
return m.postRateLimit(
|
|
passwordChangeRateLimit,
|
|
passwordChangeRateInterval,
|
|
"password change rate limit exceeded",
|
|
"Too many password change attempts. "+
|
|
"Please try again later.",
|
|
)
|
|
}
|
|
|
|
// postRateLimit builds middleware that enforces a per-IP rate
|
|
// limit on POST requests only; all other methods pass through
|
|
// unaffected. Requests over the limit receive a 429 with the
|
|
// given response message, and each rejection is logged with the
|
|
// given log message. Clients are identified by rateLimitKey.
|
|
func (m *Middleware) postRateLimit(
|
|
limit int,
|
|
interval time.Duration,
|
|
logMessage, responseMessage string,
|
|
) func(http.Handler) http.Handler {
|
|
limiter := httprate.Limit(
|
|
limit,
|
|
interval,
|
|
httprate.WithKeyFuncs(m.rateLimitKey),
|
|
httprate.WithLimitHandler(
|
|
m.tooManyRequests(logMessage, responseMessage),
|
|
),
|
|
)
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
limited := limiter(next)
|
|
|
|
return http.HandlerFunc(func(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
// Only rate-limit POST requests.
|
|
if r.Method != http.MethodPost {
|
|
next.ServeHTTP(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
limited.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
perEntrypoint := httprate.Limit(
|
|
m.params.Config.ReceiverRateLimit,
|
|
receiverRateInterval,
|
|
httprate.WithKeyFuncs(
|
|
m.rateLimitKey,
|
|
httprate.KeyByEndpoint,
|
|
),
|
|
httprate.WithLimitHandler(m.tooManyRequests(
|
|
"webhook receiver rate limit exceeded",
|
|
"Too many requests. Please slow down.",
|
|
)),
|
|
)
|
|
|
|
aggregate := httprate.Limit(
|
|
receiverAggregateLimit(m.params.Config.ReceiverRateLimit),
|
|
receiverRateInterval,
|
|
httprate.WithKeyFuncs(m.rateLimitKey),
|
|
httprate.WithLimitHandler(m.tooManyRequests(
|
|
"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
|
|
}
|