All checks were successful
check / check (push) Superseded by a newer commit; never tested
forwardedClientAddr now walks the header values in reverse with strings.LastIndexByte instead of joining and splitting, so allocation is bounded by the 64-hop cap rather than by header length: 1.6 MB per call becomes 16 bytes for a 1 MB chain. Semantics are unchanged, verified by differential testing against the previous implementation.
266 lines
8.5 KiB
Go
266 lines
8.5 KiB
Go
package middleware
|
|
|
|
import (
|
|
"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
|
|
|
|
// 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 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.
|
|
func (m *Middleware) ReceiverRateLimit() func(http.Handler) http.Handler {
|
|
return 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.",
|
|
)),
|
|
)
|
|
}
|