fix: only trust proxy headers from RFC1918/loopback sources (closes #44)

realIP() now parses RemoteAddr and checks if the source IP is in
RFC1918 (10/8, 172.16/12, 192.168/16), loopback (127/8), or IPv6
ULA/loopback ranges before trusting X-Real-IP or X-Forwarded-For
headers. Public source IPs have headers ignored (fail closed).

This prevents attackers from spoofing X-Forwarded-For to bypass
the login rate limiter.
This commit is contained in:
2026-02-15 22:01:54 -08:00
parent e9bf63d18b
commit b1a6fd5fca
2 changed files with 122 additions and 12 deletions

View File

@@ -111,10 +111,53 @@ func ipFromHostPort(hostPort string) string {
return host
}
// realIP extracts the client's real IP address from the request,
// checking proxy headers first (trusted reverse proxy like Traefik),
// then falling back to RemoteAddr.
// trustedProxyNets are RFC1918 and loopback CIDRs whose proxy headers we trust.
//
//nolint:gochecknoglobals // package-level constant nets parsed once
var trustedProxyNets = func() []*net.IPNet {
cidrs := []string{
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"127.0.0.0/8",
"::1/128",
"fc00::/7",
}
nets := make([]*net.IPNet, 0, len(cidrs))
for _, cidr := range cidrs {
_, n, _ := net.ParseCIDR(cidr)
nets = append(nets, n)
}
return nets
}()
// isTrustedProxy reports whether ip is in an RFC1918, loopback, or ULA range.
func isTrustedProxy(ip net.IP) bool {
for _, n := range trustedProxyNets {
if n.Contains(ip) {
return true
}
}
return false
}
// realIP extracts the client's real IP address from the request.
// Proxy headers (X-Real-IP, X-Forwarded-For) are only trusted when the
// direct connection originates from an RFC1918/loopback address.
// Otherwise, headers are ignored and RemoteAddr is used (fail closed).
func realIP(r *http.Request) string {
addr := ipFromHostPort(r.RemoteAddr)
remoteIP := net.ParseIP(addr)
// Only trust proxy headers from private/loopback sources.
if remoteIP == nil || !isTrustedProxy(remoteIP) {
return addr
}
// 1. X-Real-IP (set by Traefik/nginx)
if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); ip != "" {
return ip
@@ -130,7 +173,7 @@ func realIP(r *http.Request) string {
}
// 3. Fall back to RemoteAddr
return ipFromHostPort(r.RemoteAddr)
return addr
}
// CORS returns CORS middleware.