package middleware import ( "math" "net/http" "net/netip" "slices" "strings" "time" "github.com/go-chi/httprate" ) const ( // loginRateLimit is the maximum number of FAILED login attempts // one client may make against one submitted username per // interval before further failures are answered 429. Successful // attempts are never counted and never throttled — see // loginGuard. loginRateLimit = 5 // loginRateInterval is the time window for the login failure // 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 // ipv6BucketBits is the prefix length IPv6 clients are bucketed // on. A routed /64 is the normal residential and mobile // allocation, so it is the unit an attacker gets addresses in // and therefore the unit worth limiting. ipv6BucketBits = 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("") } // bucketKey is the rate-limit bucket identity of a client address. // IPv4 keys on the full address; IPv6 keys on its /64 prefix, // because keying IPv6 per /128 lets one ordinary subscriber rotate // source addresses inside its own routed /64 and mint a fresh bucket // per request — evading every limiter here at the network layer, // with no spoofing and nothing to detect. // // An IPv4-mapped address (::ffff:1.2.3.4) is keyed as the IPv4 // address it carries, never masked to a /64: mapped form all shares // the ::ffff:0:0/96 prefix, so masking would collapse every IPv4 // client reaching a proxy that emits it into one bucket. Callers // pass addresses through normalizeAddr, which already unmaps; the // unmap here keeps the property true of the key function itself. // // The two families cannot collide: an IPv4 key is a bare dotted // quad, and an IPv6 key always carries a "/64" suffix. func bucketKey(addr netip.Addr) string { addr = addr.Unmap() if addr.Is4() { return addr.String() } // Prefix errors only on a negative bit count, on over 32 bits // for an IPv4 address, or on over 128 for IPv6. The count here // is the constant 64 and the IPv4 case returned above, so the // error is unreachable. (The zero Addr does not error either: it // yields the zero Prefix. Neither call site can produce one, // since both parse the address first.) prefix, _ := addr.Prefix(ipv6BucketBits) 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. 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. // // The address that identifies the client is then 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 { // 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. return r.RemoteAddr } peer = normalizeAddr(peer) if !m.isTrustedProxy(peer) { return bucketKey(peer) } if addr, ok := m.forwardedClientAddr(r); ok { return bucketKey(addr) } return bucketKey(peer) } // tooManyRequests returns the 429 handler used by the // 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 { return func(w http.ResponseWriter, r *http.Request) { m.log.Warn(logMessage, "path", r.URL.Path) http.Error(w, responseMessage, http.StatusTooManyRequests) } } // 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) } } // 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. // // Unlike the login POST this limit is still spent on arrival, which // is safe here: RequireAuth runs ahead of it, so only a request // already carrying a valid session can reach the bucket, and an // operator locked out of changing a password can still log in. 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.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 }