Count an IPv4-mapped login client by its IPv4 address (closes #66)
check / check (push) Successful in 2m33s
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:
@@ -105,7 +105,11 @@ address, counting an IPv6 client by its /64; an attempt over the limit is
|
|||||||
refused with 429 and a `Retry-After` header. Behind a reverse proxy the client
|
refused with 429 and a `Retry-After` header. Behind a reverse proxy the client
|
||||||
address comes from `X-Forwarded-For` only when the proxy's address is in
|
address comes from `X-Forwarded-For` only when the proxy's address is in
|
||||||
`trusted_proxies`; otherwise all users behind the proxy are counted as one
|
`trusted_proxies`; otherwise all users behind the proxy are counted as one
|
||||||
client.
|
client. With the default `trusted_proxies` (the RFC 1918 ranges), a client
|
||||||
|
with a private address can choose the address it is counted by through its own
|
||||||
|
`X-Forwarded-For`, whether it connects directly or through the proxy, because
|
||||||
|
its own address is trusted too. Setting `trusted_proxies` to the proxy's own
|
||||||
|
address closes this.
|
||||||
|
|
||||||
### Source Hosts
|
### Source Hosts
|
||||||
|
|
||||||
@@ -212,7 +216,8 @@ Key settings in more detail:
|
|||||||
inside one of these ranges; the logged and login-recorded client
|
inside one of these ranges; the logged and login-recorded client
|
||||||
address is then the rightmost forwarded entry that is not itself a
|
address is then the rightmost forwarded entry that is not itself a
|
||||||
trusted proxy. Otherwise the direct peer address is used and the header
|
trusted proxy. Otherwise the direct peer address is used and the header
|
||||||
is ignored, so a client connecting directly cannot spoof its address.
|
is ignored, so a client connecting directly from an address outside
|
||||||
|
these ranges cannot spoof its address.
|
||||||
An omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`,
|
An omitted key defaults to the RFC 1918 private ranges (`10.0.0.0/8`,
|
||||||
`172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a
|
`172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a
|
||||||
proxy on a private network; an explicitly empty list (`[]`) trusts no
|
proxy on a private network; an explicitly empty list (`[]`) trusts no
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ exhaustion
|
|||||||
- 2026-09-28 rate limit the login form (closes #66): `POST /` is limited to 5
|
- 2026-09-28 rate limit the login form (closes #66): `POST /` is limited to 5
|
||||||
attempts per minute per client address, and an attempt over the limit is
|
attempts per minute per client address, and an attempt over the limit is
|
||||||
refused with 429 and a `Retry-After` header; the address is the one
|
refused with 429 and a `Retry-After` header; the address is the one
|
||||||
`internal/clientip` resolves through `trusted_proxies`, and an IPv6 client is
|
`internal/clientip` resolves through `trusted_proxies`, an IPv6 client is
|
||||||
counted by its /64; the limit is a `RateLimit` middleware in
|
counted by its /64, and an IPv4-mapped address as the IPv4 address it
|
||||||
`internal/middleware` on `github.com/go-chi/httprate`, which the image routes
|
carries; the limit is a `RateLimit` middleware in `internal/middleware` on
|
||||||
can reuse; the library keeps counts for the current and the previous minute
|
`github.com/go-chi/httprate`, which the image routes can reuse; the library
|
||||||
only; documented in `README.md`.
|
keeps counts for the current and the previous minute only; documented in
|
||||||
|
`README.md`.
|
||||||
- 2026-09-28 refuse an unparseable `exp` on `/v1/image/` and log swallowed
|
- 2026-09-28 refuse an unparseable `exp` on `/v1/image/` and log swallowed
|
||||||
cache errors (closes #72): an `exp` in the URL that is not a whole
|
cache errors (closes #72): an `exp` in the URL that is not a whole
|
||||||
number, an empty `exp=` included, is a 400 naming `exp` and the value,
|
number, an empty `exp=` included, is a 400 naming `exp` and the value,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
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
|
// Requests and a Retry-After header. Clients are told apart by the address
|
||||||
// the ClientIP middleware stored in the request context, so ClientIP must
|
// 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
|
// 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
|
// holds whole; an IPv4-mapped address (::ffff:a.b.c.d) is counted as the
|
||||||
// window, so memory stays bounded.
|
// 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(
|
func (s *Middleware) RateLimit(
|
||||||
requestLimit int, window time.Duration,
|
requestLimit int, window time.Duration,
|
||||||
) func(http.Handler) http.Handler {
|
) func(http.Handler) http.Handler {
|
||||||
return httprate.LimitBy(requestLimit, window,
|
return httprate.LimitBy(requestLimit, window,
|
||||||
func(r *http.Request) (string, error) {
|
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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user