Count an IPv4-mapped login client by its IPv4 address (closes #66)
check / check (push) Successful in 2m33s

A proxy on a dual-stack listener forwards an IPv4 client as ::ffff:a.b.c.d,
whose /64 is the same for every IPv4 client, so one client's failed logins
refused everyone's. The rate limit key now unmaps the address first.

README.md now says that with the default trusted_proxies a client with a
private address can choose its counted address through X-Forwarded-For, and
that setting trusted_proxies to the proxy's own address closes this.

Model: opus-5-5
This commit is contained in:
2026-09-28 22:49:07 +00:00
parent 39051ee4a3
commit 194c0ded63
3 changed files with 26 additions and 10 deletions
+13 -3
View File
@@ -4,6 +4,7 @@ package middleware
import (
"log/slog"
"net/http"
"net/netip"
"time"
basicauth "github.com/99designs/basicauth-go"
@@ -94,14 +95,23 @@ func (s *Middleware) ClientIP() func(http.Handler) http.Handler {
// Requests and a Retry-After header. Clients are told apart by the address
// the ClientIP middleware stored in the request context, so ClientIP must
// run first. An IPv6 client is counted by its /64, which one client usually
// holds whole. Counts are kept only for the current and the previous
// window, so memory stays bounded.
// holds whole; an IPv4-mapped address (::ffff:a.b.c.d) is counted as the
// IPv4 address it carries, since every such address falls in the same /64.
// Counts are kept only for the current and the previous window, so memory
// stays bounded.
func (s *Middleware) RateLimit(
requestLimit int, window time.Duration,
) func(http.Handler) http.Handler {
return httprate.LimitBy(requestLimit, window,
func(r *http.Request) (string, error) {
return httprate.CanonicalizeIP(clientip.FromContext(r.Context())), nil
ip := clientip.FromContext(r.Context())
addr, err := netip.ParseAddr(ip)
if err == nil {
ip = addr.Unmap().String()
}
return httprate.CanonicalizeIP(ip), nil
})
}