diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index bc650b8..69fa447 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -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. diff --git a/internal/middleware/realip_test.go b/internal/middleware/realip_test.go index 8cd1632..f38aa48 100644 --- a/internal/middleware/realip_test.go +++ b/internal/middleware/realip_test.go @@ -2,6 +2,7 @@ package middleware //nolint:testpackage // tests unexported realIP function import ( "context" + "net" "net/http" "testing" ) @@ -16,54 +17,98 @@ func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test xff string want string }{ + // === Trusted proxy (RFC1918 / loopback) — headers ARE honoured === { - name: "X-Real-IP takes priority", + name: "trusted: X-Real-IP from 10.x", remoteAddr: "10.0.0.1:1234", xRealIP: "203.0.113.5", xff: "198.51.100.1, 10.0.0.1", want: "203.0.113.5", }, { - name: "X-Forwarded-For used when no X-Real-IP", + name: "trusted: XFF from 10.x when no X-Real-IP", remoteAddr: "10.0.0.1:1234", xff: "198.51.100.1, 10.0.0.1", want: "198.51.100.1", }, { - name: "X-Forwarded-For single IP", + name: "trusted: XFF single IP from 10.x", remoteAddr: "10.0.0.1:1234", xff: "203.0.113.10", want: "203.0.113.10", }, { - name: "falls back to RemoteAddr", + name: "trusted: falls back to RemoteAddr (192.168.x)", remoteAddr: "192.168.1.1:5678", want: "192.168.1.1", }, { - name: "RemoteAddr without port", + name: "trusted: RemoteAddr without port", remoteAddr: "192.168.1.1", want: "192.168.1.1", }, { - name: "X-Real-IP with whitespace", + name: "trusted: X-Real-IP with whitespace from 10.x", remoteAddr: "10.0.0.1:1234", xRealIP: " 203.0.113.5 ", want: "203.0.113.5", }, { - name: "X-Forwarded-For with whitespace", + name: "trusted: XFF with whitespace from 10.x", remoteAddr: "10.0.0.1:1234", xff: " 198.51.100.1 , 10.0.0.1", want: "198.51.100.1", }, { - name: "empty X-Real-IP falls through to XFF", + name: "trusted: empty X-Real-IP falls through to XFF from 10.x", remoteAddr: "10.0.0.1:1234", xRealIP: " ", xff: "198.51.100.1", want: "198.51.100.1", }, + { + name: "trusted: loopback honours X-Real-IP", + remoteAddr: "127.0.0.1:9999", + xRealIP: "93.184.216.34", + want: "93.184.216.34", + }, + { + name: "trusted: 172.16.x honours XFF", + remoteAddr: "172.16.0.1:4321", + xff: "8.8.8.8", + want: "8.8.8.8", + }, + + // === Untrusted proxy (public IP) — headers IGNORED, use RemoteAddr === + { + name: "untrusted: X-Real-IP ignored from public IP", + remoteAddr: "203.0.113.50:1234", + xRealIP: "10.0.0.1", + want: "203.0.113.50", + }, + { + name: "untrusted: XFF ignored from public IP", + remoteAddr: "198.51.100.99:5678", + xff: "10.0.0.1, 192.168.1.1", + want: "198.51.100.99", + }, + { + name: "untrusted: both headers ignored from public IP", + remoteAddr: "8.8.8.8:443", + xRealIP: "1.2.3.4", + xff: "5.6.7.8", + want: "8.8.8.8", + }, + { + name: "untrusted: no headers, public RemoteAddr", + remoteAddr: "93.184.216.34:8080", + want: "93.184.216.34", + }, + { + name: "untrusted: public RemoteAddr without port", + remoteAddr: "93.184.216.34", + want: "93.184.216.34", + }, } for _, tt := range tests { @@ -88,3 +133,25 @@ func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test }) } } + +func TestIsTrustedProxy(t *testing.T) { + t.Parallel() + + trusted := []string{"10.0.0.1", "10.255.255.255", "172.16.0.1", "172.31.255.255", + "192.168.0.1", "192.168.255.255", "127.0.0.1", "127.255.255.255", "::1"} + untrusted := []string{"8.8.8.8", "203.0.113.1", "172.32.0.1", "11.0.0.1", "2001:db8::1"} + + for _, addr := range trusted { + ip := net.ParseIP(addr) + if !isTrustedProxy(ip) { + t.Errorf("expected %s to be trusted", addr) + } + } + + for _, addr := range untrusted { + ip := net.ParseIP(addr) + if isTrustedProxy(ip) { + t.Errorf("expected %s to be untrusted", addr) + } + } +}