Skip to content

Commit b71dbcd

Browse files
committed
make RealIP middleware compatible with Cloudflare/CDN setups
change header priority to: X-Real-IP → CF-Connecting-IP → X-Forwarded-For (leftmost) → RemoteAddr. only accept public IPs from headers, skip private/loopback/link-local. this fixes issue where Cloudflare edge server IPs were returned instead of actual client IPs. Related to #40
1 parent 6e22daa commit b71dbcd

File tree

7 files changed

+209
-218
lines changed

7 files changed

+209
-218
lines changed

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,14 @@ Compresses response with gzip.
157157

158158
### RealIP middleware
159159

160-
RealIP is a middleware that sets a http.Request's RemoteAddr to the results of parsing either the X-Forwarded-For or X-Real-IP headers.
160+
RealIP is a middleware that sets a http.Request's RemoteAddr to the results of parsing various headers that contain the client's real IP address. It checks headers in the following priority order:
161+
162+
1. `X-Real-IP` - trusted proxy (nginx/reproxy) sets this to actual client
163+
2. `CF-Connecting-IP` - Cloudflare's header for original client
164+
3. `X-Forwarded-For` - leftmost public IP (original client in CDN/proxy chain)
165+
4. `RemoteAddr` - fallback for direct connections
166+
167+
Only public IPs are accepted from headers; private/loopback/link-local IPs are skipped. This makes the middleware compatible with CDN setups like Cloudflare where the leftmost IP in `X-Forwarded-For` is the actual client.
161168

162169
### Maybe middleware
163170

benchmarks_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ func TestBenchmark_CustomTimeRange(t *testing.T) {
272272
bench := NewBenchmarks().WithTimeRange(tt.timeRange)
273273
now := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
274274

275-
// Add data points
275+
// add data points
276276
for i := 0; i < tt.dataPoints; i++ {
277277
bench.nowFn = func() time.Time {
278278
return now.Add(time.Duration(i) * time.Second)

logger/logger.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,8 @@ func (l *Middleware) getBody(r *http.Request) string {
220220

221221
// "The Server will close the request body. The ServeHTTP Handler does not need to."
222222
// https://golang.org/pkg/net/http/#Request
223-
// So we can use ioutil.NopCloser() to make io.ReadCloser.
224-
// Note that below assignment is not approved by the docs:
223+
// so we can use ioutil.NopCloser() to make io.ReadCloser.
224+
// note that below assignment is not approved by the docs:
225225
// "Except for reading the body, handlers should not modify the provided Request."
226226
// https://golang.org/pkg/net/http/#Handler
227227
r.Body = io.NopCloser(reader)

middleware.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ func Wrap(handler http.Handler, mws ...func(http.Handler) http.Handler) http.Han
1919
return handler
2020
}
2121

22-
2322
// AppInfo adds custom app-info to the response header
2423
func AppInfo(app, author, version string) func(http.Handler) http.Handler {
2524
f := func(h http.Handler) http.Handler {
@@ -146,13 +145,19 @@ func Maybe(mw func(http.Handler) http.Handler, maybeFn func(r *http.Request) boo
146145
}
147146
}
148147

149-
// RealIP is a middleware that sets a http.Request's RemoteAddr to the results
150-
// of parsing either the X-Forwarded-For or X-Real-IP headers.
148+
// RealIP is a middleware that sets a http.Request's RemoteAddr to the client's real IP.
149+
// It checks headers in the following priority order:
150+
// 1. X-Real-IP - trusted proxy (nginx/reproxy) sets this to actual client
151+
// 2. CF-Connecting-IP - Cloudflare's header for original client
152+
// 3. X-Forwarded-For - leftmost public IP (original client in CDN/proxy chain)
153+
// 4. RemoteAddr - fallback for direct connections
154+
//
155+
// Only public IPs are accepted from headers; private/loopback/link-local IPs are skipped.
151156
//
152157
// This middleware should only be used if user can trust the headers sent with request.
153158
// If reverse proxies are configured to pass along arbitrary header values from the client,
154159
// or if this middleware used without a reverse proxy, malicious clients could set anything
155-
// as X-Forwarded-For header and attack the server in various ways.
160+
// as these headers and spoof their IP address.
156161
func RealIP(h http.Handler) http.Handler {
157162
fn := func(w http.ResponseWriter, r *http.Request) {
158163
if rip, err := realip.Get(r); err == nil {

nocache.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,22 @@ var etagHeaders = []string{
2929
// a router (or subrouter) from being cached by an upstream proxy and/or client.
3030
//
3131
// As per http://wiki.nginx.org/HttpProxyModule - NoCache sets:
32-
// Expires: Thu, 01 Jan 1970 00:00:00 UTC
33-
// Cache-Control: no-cache, private, max-age=0
34-
// X-Accel-Expires: 0
35-
// Pragma: no-cache (for HTTP/1.0 proxies/clients)
32+
//
33+
// Expires: Thu, 01 Jan 1970 00:00:00 UTC
34+
// Cache-Control: no-cache, private, max-age=0
35+
// X-Accel-Expires: 0
36+
// Pragma: no-cache (for HTTP/1.0 proxies/clients)
3637
func NoCache(h http.Handler) http.Handler {
3738
fn := func(w http.ResponseWriter, r *http.Request) {
3839

39-
// Delete any ETag headers that may have been set
40+
// delete any ETag headers that may have been set
4041
for _, v := range etagHeaders {
4142
if r.Header.Get(v) != "" {
4243
r.Header.Del(v)
4344
}
4445
}
4546

46-
// Set our NoCache headers
47+
// set our NoCache headers
4748
for k, v := range noCacheHeaders {
4849
w.Header().Set(k, v)
4950
}

realip/real.go

Lines changed: 53 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -33,49 +33,73 @@ var privateRanges = []ipRange{
3333
{start: net.ParseIP("fe80::"), end: net.ParseIP("febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff")},
3434
}
3535

36-
// Get returns real ip from the given request
37-
// Prioritize public IPs over private IPs
36+
// Get returns real IP from the given request.
37+
// It checks headers in the following priority order:
38+
// 1. X-Real-IP - trusted proxy (nginx/reproxy) sets this to actual client
39+
// 2. CF-Connecting-IP - Cloudflare's header for original client
40+
// 3. X-Forwarded-For - leftmost public IP (original client in CDN chain)
41+
// 4. RemoteAddr - fallback for direct connections
42+
//
43+
// Only public IPs are accepted from headers; private/loopback/link-local IPs are skipped.
3844
func Get(r *http.Request) (string, error) {
39-
var firstIP string
40-
for _, h := range []string{"X-Forwarded-For", "X-Real-Ip"} {
41-
addresses := strings.Split(r.Header.Get(h), ",")
42-
for i := len(addresses) - 1; i >= 0; i-- {
43-
ip := strings.TrimSpace(addresses[i])
44-
realIP := net.ParseIP(ip)
45-
if firstIP == "" && realIP != nil {
46-
firstIP = ip
47-
}
48-
// Guard against nil realIP
49-
if realIP == nil || !realIP.IsGlobalUnicast() || isPrivateSubnet(realIP) {
50-
continue
45+
// check X-Real-IP first (single value, set by trusted proxy)
46+
if xRealIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); xRealIP != "" {
47+
if ip := net.ParseIP(xRealIP); isPublicIP(ip) {
48+
return xRealIP, nil
49+
}
50+
}
51+
52+
// check CF-Connecting-IP (Cloudflare's header)
53+
if cfIP := strings.TrimSpace(r.Header.Get("CF-Connecting-IP")); cfIP != "" {
54+
if ip := net.ParseIP(cfIP); isPublicIP(ip) {
55+
return cfIP, nil
56+
}
57+
}
58+
59+
// check X-Forwarded-For, find leftmost public IP
60+
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
61+
addresses := strings.Split(xff, ",")
62+
for _, addr := range addresses {
63+
ip := strings.TrimSpace(addr)
64+
if parsedIP := net.ParseIP(ip); isPublicIP(parsedIP) {
65+
return ip, nil
5166
}
52-
return ip, nil
5367
}
5468
}
5569

56-
if firstIP != "" {
57-
return firstIP, nil
70+
// fall back to RemoteAddr
71+
return parseRemoteAddr(r.RemoteAddr)
72+
}
73+
74+
// isPublicIP checks if the IP is a valid public (globally routable) IP address.
75+
func isPublicIP(ip net.IP) bool {
76+
if ip == nil {
77+
return false
78+
}
79+
if !ip.IsGlobalUnicast() {
80+
return false
5881
}
82+
return !isPrivateSubnet(ip)
83+
}
5984

60-
// handle RemoteAddr which may be just an IP or IP:port
61-
remoteIP := r.RemoteAddr
85+
// parseRemoteAddr extracts and validates IP from RemoteAddr (handles both "ip" and "ip:port" formats).
86+
func parseRemoteAddr(remoteAddr string) (string, error) {
87+
if remoteAddr == "" {
88+
return "", fmt.Errorf("empty remote address")
89+
}
6290

6391
// try to extract host from host:port format
64-
host, _, err := net.SplitHostPort(remoteIP)
92+
host, _, err := net.SplitHostPort(remoteAddr)
6593
if err == nil {
66-
remoteIP = host
94+
remoteAddr = host
6795
}
6896

69-
// at this point remoteIP could be either:
70-
// 1. the host part extracted from host:port
71-
// 2. yhe original RemoteAddr if it doesn't contain a port
72-
73-
// try to parse it as a valid IP address
74-
if netIP := net.ParseIP(remoteIP); netIP == nil {
75-
return "", fmt.Errorf("no valid ip found in %q", r.RemoteAddr)
97+
// validate it's a proper IP address
98+
if netIP := net.ParseIP(remoteAddr); netIP == nil {
99+
return "", fmt.Errorf("no valid ip found in %q", remoteAddr)
76100
}
77101

78-
return remoteIP, nil
102+
return remoteAddr, nil
79103
}
80104

81105
// isPrivateSubnet - check to see if this ip is in a private subnet

0 commit comments

Comments
 (0)