Cap the X-Forwarded-For hop walk at 64 entries (closes #124)
All checks were successful
check / check (push) Successful in 4m9s
All checks were successful
check / check (push) Successful in 4m9s
The chain walk in the rate-limit key function had no bound on hop count. It runs whenever the direct peer is a trusted proxy, which is the normal production deployment, so any client could pad X-Forwarded-For to MaxHeaderBytes (~50k hops, ~0.9 MB) and make the key function walk all of it on the unauthenticated receiver endpoint before the request was rate-limited. Only the last 64 entries are examined now; real chains are one to three hops. A chain longer than the cap runs out of hops and falls back to the peer address, the same fail-closed direction an unreadable hop already took. Bucket assignment for real chains is unchanged. Also corrects the comment on the unparseable-RemoteAddr fallback: it claimed keying on the raw value avoids collapsing those peers into one bucket, but on a Unix-socket listener every peer carries the same RemoteAddr and does share one bucket. The behaviour is fail-closed and unchanged; only the comment was wrong.
This commit is contained in:
@@ -32,6 +32,13 @@ const (
|
||||
// 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
|
||||
@@ -70,12 +77,19 @@ func (m *Middleware) isTrustedProxy(addr netip.Addr) bool {
|
||||
// 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.
|
||||
func (m *Middleware) forwardedClientAddr(
|
||||
r *http.Request,
|
||||
) (netip.Addr, bool) {
|
||||
hops := strings.Split(
|
||||
strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",",
|
||||
)
|
||||
if len(hops) > maxForwardedHops {
|
||||
hops = hops[len(hops)-maxForwardedHops:]
|
||||
}
|
||||
|
||||
for _, hop := range slices.Backward(hops) {
|
||||
hop = strings.TrimSpace(hop)
|
||||
@@ -113,8 +127,10 @@ 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 rather than collapsing such peers into one
|
||||
// shared bucket.
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user