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 }