Repository: grpc-ecosystem/go-grpc-middleware
Pull request #695 (2024-02-06 11:04:10)

Extent realip interceptors with ip selection based on proxy count and list
The rightmost IP is not always the client IP. One example is Google:
https://cloud.google.com/load-balancing/docs/https#x-forwarded-for_header

The PR extends the IP selection for `X-Forwarded-For` based on [MDN Selecting an IP address](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For#selecting_an_ip_address).

Just so you know, it is possible to configure both at the same time. 
The user needs to be cautious when configuring these for IP selection and normally has to pick `TrustedProxies` or `TrustedProxiesCount`.

## Changes

Two new methods `UnaryServerInterceptorOpts` and `StreamServerInterceptorOpts` and options:
```go
type Opts struct {
	TrustedPeers []netip.Prefix
	TrustedProxies []netip.Prefix
	TrustedProxiesCount uint
	Headers []string
}
```

The `UnaryServerInterceptor` and `StreamServerInterceptor` are kept for backward compatibility.

## Verification

The `realip_test.go` is extended with additional test cases.



Patch:
diff --git a/interceptors/realip/doc.go b/interceptors/realip/doc.go
index 19f3d52..7930dae 100644
--- a/interceptors/realip/doc.go
+++ b/interceptors/realip/doc.go
@@ -36,19 +36,33 @@ the real IP from the request headers. If the peer address is not found to be
 within one of the trusted networks, the peer address will be returned as the
 real IP.
 
-"trusted" in this context means that the peer is configured to overwrite the
+"trusted peer" in this context means that the peer is configured to overwrite the
 header value with the real IP. This is typically done by a proxy or load
 balancer that is configured to forward the real IP of the client in a header
 value. Alternatively, the peer may be configured to append the real IP to the
 header value. In this case, the middleware will use the last, rightmost, IP
 address in the header as the real IP. Most load balancers, such as NGINX, AWS
-ELB, and Google Cloud Load Balancer, are configured to append the real IP to
-the header value as their default action.
+ELB, are configured to append the real IP to the header value as their default action.
+However, Google Cloud Load Balancer for `X-Forwarded-For` follows the pattern:
+`<client-ip>,<load-balancer-ip>`. Hence we need to have an ability to exact the
+real ip from the header ignoring the LB/proxy IPs.
 
-To mitigate the risk of a denial of service by proxy of a malicious header,
-the middleware validates that the header value contains a valid IP address. Only
-if a valid IP address is found will the middleware use that value as the real
-IP.
+### Supported Methods for Extracting Real IP:
+
+This is based on
+[Selecting an IP address](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For#selecting_an_ip_address).
+
+ 1. Trusted Proxy Count
+
+With this method, the count of reverse proxies between the internet and the server is configured.
+The middleware searches the `X-Forwarded-For` IP list from the rightmost by that count.
+
+ 2. Trusted Proxy List
+
+Alternatively, you can configure a list of trusted reverse proxies by specifying their
+IPs or IP ranges. The middleware will then search the `X-Forwarded-For` IP list from
+the rightmost, skipping all addresses that are on the trusted proxy list.
+The first non-matching address is considered the target address.
 
 # Individual IP addresses as trusted peers
 
diff --git a/interceptors/realip/options.go b/interceptors/realip/options.go
new file mode 100644
index 0000000..5f62134
--- /dev/null
+++ b/interceptors/realip/options.go
@@ -0,0 +1,59 @@
+// Copyright (c) The go-grpc-middleware Authors.
+// Licensed under the Apache License 2.0.
+
+package realip
+
+import "net/netip"
+
+// options represents the configuration options for the realip middleware.
+type options struct {
+	// trustedPeers is a list of trusted peers network prefixes.
+	trustedPeers []netip.Prefix
+	// trustedProxies is a list of trusted proxies network prefixes.
+	// The first rightmost non-matching IP when going through X-Forwarded-For is considered the client IP.
+	trustedProxies []netip.Prefix
+	// trustedProxiesCount specifies the number of proxies in front that may append X-Forwarded-For.
+	// It defaults to 0.
+	trustedProxiesCount uint
+	// headers specifies the headers to use in real IP extraction when the request is from a trusted peer.
+	headers []string
+}
+
+// An Option lets you add options to realip interceptors using With* functions.
+type Option func(*options)
+
+func evaluateOpts(opts []Option) *options {
+	optCopy := &options{}
+	for _, o := range opts {
+		o(optCopy)
+	}
+	return optCopy
+}
+
+// WithTrustedPeers sets the trusted peers network prefixes.
+func WithTrustedPeers(peers []netip.Prefix) Option {
+	return func(o *options) {
+		o.trustedPeers = peers
+	}
+}
+
+// WithTrustedProxies sets the trusted proxies network prefixes.
+func WithTrustedProxies(proxies []netip.Prefix) Option {
+	return func(o *options) {
+		o.trustedProxies = proxies
+	}
+}
+
+// WithTrustedProxiesCount sets the number of trusted proxies that may append X-Forwarded-For.
+func WithTrustedProxiesCount(count uint) Option {
+	return func(o *options) {
+		o.trustedProxiesCount = count
+	}
+}
+
+// WithHeaders sets the headers to use in real IP extraction for requests from trusted peers.
+func WithHeaders(headers []string) Option {
+	return func(o *options) {
+		o.headers = headers
+	}
+}
diff --git a/interceptors/realip/realip.go b/interceptors/realip/realip.go
index 41b2cde..bd0f2d2 100644
--- a/interceptors/realip/realip.go
+++ b/interceptors/realip/realip.go
@@ -65,10 +65,32 @@ func getHeader(ctx context.Context, key string) string {
 	return md[strings.ToLower(key)][0]
 }
 
-func ipFromHeaders(ctx context.Context, headers []string) netip.Addr {
+func ipFromXForwardedFoR(trustedProxies []netip.Prefix, ips []string, idx int) netip.Addr {
+	for i := idx; i >= 0; i-- {
+		h := strings.TrimSpace(ips[i])
+		ip, err := netip.ParseAddr(h)
+		if err != nil {
+			return noIP
+		}
+		if !ipInNets(ip, trustedProxies) {
+			return ip
+		}
+	}
+	return noIP
+}
+
+func ipFromHeaders(ctx context.Context, headers []string, trustedProxies []netip.Prefix, trustedProxyCnt uint) netip.Addr {
 	for _, header := range headers {
 		a := strings.Split(getHeader(ctx, header), ",")
-		h := strings.TrimSpace(a[len(a)-1])
+		idx := len(a) - 1
+		if header == XForwardedFor {
+			idx = idx - int(trustedProxyCnt)
+			if idx < 0 {
+				continue
+			}
+			return ipFromXForwardedFoR(trustedProxies, a, idx)
+		}
+		h := strings.TrimSpace(a[idx])
 		ip, err := netip.ParseAddr(h)
 		if err == nil {
 			return ip
@@ -77,7 +99,7 @@ func ipFromHeaders(ctx context.Context, headers []string) netip.Addr {
 	return noIP
 }
 
-func getRemoteIP(ctx context.Context, trustedPeers []netip.Prefix, headers []string) netip.Addr {
+func getRemoteIP(ctx context.Context, trustedPeers, trustedProxies []netip.Prefix, headers []string, proxyCnt uint) netip.Addr {
 	pr := remotePeer(ctx)
 	if pr == nil {
 		return noIP
@@ -92,7 +114,7 @@ func getRemoteIP(ctx context.Context, trustedPeers []netip.Prefix, headers []str
 	if len(trustedPeers) == 0 || !ipInNets(ip, trustedPeers) {
 		return ip
 	}
-	if ip := ipFromHeaders(ctx, headers); ip != noIP {
+	if ip := ipFromHeaders(ctx, headers, trustedProxies, proxyCnt); ip != noIP {
 		return ip
 	}
 	// No ip from the headers, return the peer ip.
@@ -111,9 +133,27 @@ func (s *serverStream) Context() context.Context {
 // UnaryServerInterceptor returns a new unary server interceptor that extracts the real client IP from request headers.
 // It checks if the request comes from a trusted peer, and if so, extracts the IP from the configured headers.
 // The real IP is added to the request context.
+// See UnaryServerInterceptorOpts as it allows to configure trusted proxy ips list and count that should work better with Google LB
 func UnaryServerInterceptor(trustedPeers []netip.Prefix, headers []string) grpc.UnaryServerInterceptor {
+	return UnaryServerInterceptorOpts(WithTrustedPeers(trustedPeers), WithHeaders(headers))
+}
+
+// StreamServerInterceptor returns a new stream server interceptor that extracts the real client IP from request headers.
+// It checks if the request comes from a trusted peer, and if so, extracts the IP from the configured headers.
+// The real IP is added to the request context.
+// See UnaryServerInterceptorOpts as it allows to configure trusted proxy ips list and count that should work better with Google LB
+func StreamServerInterceptor(trustedPeers []netip.Prefix, headers []string) grpc.StreamServerInterceptor {
+	return StreamServerInterceptorOpts(WithTrustedPeers(trustedPeers), WithHeaders(headers))
+}
+
+// UnaryServerInterceptorOpts returns a new unary server interceptor that extracts the real client IP from request headers.
+// It checks if the request comes from a trusted peer, validates headers against trusted proxies list and trusted proxies count
+// then it extracts the IP from the configured headers.
+// The real IP is added to the request context.
+func UnaryServerInterceptorOpts(opts ...Option) grpc.UnaryServerInterceptor {
+	o := evaluateOpts(opts)
 	return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
-		ip := getRemoteIP(ctx, trustedPeers, headers)
+		ip := getRemoteIP(ctx, o.trustedPeers, o.trustedProxies, o.headers, o.trustedProxiesCount)
 		if ip != noIP {
 			ctx = context.WithValue(ctx, realipKey{}, ip)
 		}
@@ -121,12 +161,14 @@ func UnaryServerInterceptor(trustedPeers []netip.Prefix, headers []string) grpc.
 	}
 }
 
-// StreamServerInterceptor returns a new stream server interceptor that extracts the real client IP from request headers.
-// It checks if the request comes from a trusted peer, and if so, extracts the IP from the configured headers.
+// StreamServerInterceptorOpts returns a new stream server interceptor that extracts the real client IP from request headers.
+// It checks if the request comes from a trusted peer, validates headers against trusted proxies list and trusted proxies count
+// then it extracts the IP from the configured headers.
 // The real IP is added to the request context.
-func StreamServerInterceptor(trustedPeers []netip.Prefix, headers []string) grpc.StreamServerInterceptor {
+func StreamServerInterceptorOpts(opts ...Option) grpc.StreamServerInterceptor {
+	o := evaluateOpts(opts)
 	return func(srv any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
-		ip := getRemoteIP(stream.Context(), trustedPeers, headers)
+		ip := getRemoteIP(stream.Context(), o.trustedPeers, o.trustedProxies, o.headers, o.trustedProxiesCount)
 		if ip != noIP {
 			return handler(srv, &serverStream{
 				ServerStream: stream,


Test patch:
diff --git a/interceptors/realip/examples_test.go b/interceptors/realip/examples_test.go
index 3caf0d0..12a7aa3 100644
--- a/interceptors/realip/examples_test.go
+++ b/interceptors/realip/examples_test.go
@@ -11,7 +11,7 @@ import (
 )
 
 // Simple example of a unary server initialization code.
-func ExampleUnaryServerInterceptor() {
+func ExampleUnaryServerInterceptorOpts() {
 	// Define list of trusted peers from which we accept forwarded-for and
 	// real-ip headers.
 	trustedPeers := []netip.Prefix{
@@ -19,15 +19,23 @@ func ExampleUnaryServerInterceptor() {
 	}
 	// Define headers to look for in the incoming request.
 	headers := []string{realip.XForwardedFor, realip.XRealIp}
+	// Consider that there is one proxy in front,
+	// so the real client ip will be rightmost - 1 in the csv list of X-Forwarded-For
+	// Optionally you can specify TrustedProxies
+	opts := []realip.Option{
+		realip.WithTrustedPeers(trustedPeers),
+		realip.WithHeaders(headers),
+		realip.WithTrustedProxiesCount(1),
+	}
 	_ = grpc.NewServer(
 		grpc.ChainUnaryInterceptor(
-			realip.UnaryServerInterceptor(trustedPeers, headers),
+			realip.UnaryServerInterceptorOpts(opts...),
 		),
 	)
 }
 
 // Simple example of a streaming server initialization code.
-func ExampleStreamServerInterceptor() {
+func ExampleStreamServerInterceptorOpts() {
 	// Define list of trusted peers from which we accept forwarded-for and
 	// real-ip headers.
 	trustedPeers := []netip.Prefix{
@@ -35,9 +43,17 @@ func ExampleStreamServerInterceptor() {
 	}
 	// Define headers to look for in the incoming request.
 	headers := []string{realip.XForwardedFor, realip.XRealIp}
+	// Consider that there is one proxy in front,
+	// so the real client ip will be rightmost - 1 in the csv list of X-Forwarded-For
+	// Optionally you can specify TrustedProxies
+	opts := []realip.Option{
+		realip.WithTrustedPeers(trustedPeers),
+		realip.WithHeaders(headers),
+		realip.WithTrustedProxiesCount(1),
+	}
 	_ = grpc.NewServer(
 		grpc.ChainStreamInterceptor(
-			realip.StreamServerInterceptor(trustedPeers, headers),
+			realip.StreamServerInterceptorOpts(opts...),
 		),
 	)
 }
diff --git a/interceptors/realip/realip_test.go b/interceptors/realip/realip_test.go
index 9666ed5..c5384ef 100644
--- a/interceptors/realip/realip_test.go
+++ b/interceptors/realip/realip_test.go
@@ -78,15 +78,26 @@ func private6Peer() *peer.Peer {
 }
 
 type testCase struct {
-	trustedPeers []netip.Prefix
-	headerKeys   []string
-	inputHeaders map[string]string
-	peer         *peer.Peer
-	expectedIP   netip.Addr
+	trustedPeers   []netip.Prefix
+	trustedProxies []netip.Prefix
+	proxiesCount   uint
+	headerKeys     []string
+	inputHeaders   map[string]string
+	peer           *peer.Peer
+	expectedIP     netip.Addr
+}
+
+func (c testCase) optsFromTesCase() []Option {
+	return []Option{
+		WithTrustedPeers(c.trustedPeers),
+		WithTrustedProxies(c.trustedProxies),
+		WithTrustedProxiesCount(c.proxiesCount),
+		WithHeaders(c.headerKeys),
+	}
 }
 
 func testUnaryServerInterceptor(t *testing.T, c testCase) {
-	interceptor := UnaryServerInterceptor(c.trustedPeers, c.headerKeys)
+	interceptor := UnaryServerInterceptorOpts(c.optsFromTesCase()...)
 	handler := func(ctx context.Context, req any) (any, error) {
 		ip, _ := FromContext(ctx)
 
@@ -111,7 +122,7 @@ func testUnaryServerInterceptor(t *testing.T, c testCase) {
 }
 
 func testStreamServerInterceptor(t *testing.T, c testCase) {
-	interceptor := StreamServerInterceptor(c.trustedPeers, c.headerKeys)
+	interceptor := StreamServerInterceptorOpts(c.optsFromTesCase()...)
 	handler := func(srv any, stream grpc.ServerStream) error {
 		ip, _ := FromContext(stream.Context())
 
@@ -153,7 +164,6 @@ func TestInterceptor(t *testing.T) {
 			testStreamServerInterceptor(t, tc)
 		})
 	})
-
 	t.Run("trusted peer header csv", func(t *testing.T) {
 		tc := testCase{
 			// Test that if the remote peer is trusted and the header contains
@@ -173,6 +183,89 @@ func TestInterceptor(t *testing.T) {
 			testStreamServerInterceptor(t, tc)
 		})
 	})
+	t.Run("trusted proxy list with XForwardedFor", func(t *testing.T) {
+		tc := testCase{
+			// Test that if the remote peer is trusted and the header contains
+			// a comma separated list of valid IPs,
+			// we get the first going from right to left that is not in local net
+			trustedPeers:   localnet,
+			trustedProxies: localnet,
+			headerKeys:     []string{XForwardedFor},
+			inputHeaders: map[string]string{
+				XForwardedFor: fmt.Sprintf("%s,%s", publicIP.String(), localhost.String()),
+			},
+			peer:       localhostPeer(),
+			expectedIP: publicIP,
+		}
+		t.Run("unary", func(t *testing.T) {
+			testUnaryServerInterceptor(t, tc)
+		})
+		t.Run("stream", func(t *testing.T) {
+			testStreamServerInterceptor(t, tc)
+		})
+	})
+	t.Run("trusted proxy list private net with XForwardedFor", func(t *testing.T) {
+		tc := testCase{
+			// Test that if the remote peer is trusted and the header contains
+			// a comma separated list of valid IPs,
+			// we get the first going from right to left that is not in private net
+			trustedPeers:   localnet,
+			trustedProxies: privatenet,
+			headerKeys:     []string{XForwardedFor},
+			inputHeaders: map[string]string{
+				XForwardedFor: fmt.Sprintf("%s,%s", publicIP.String(), localhost.String()),
+			},
+			peer:       localhostPeer(),
+			expectedIP: localhost,
+		}
+		t.Run("unary", func(t *testing.T) {
+			testUnaryServerInterceptor(t, tc)
+		})
+		t.Run("stream", func(t *testing.T) {
+			testStreamServerInterceptor(t, tc)
+		})
+	})
+	t.Run("trusted proxy count with XForwardedFor", func(t *testing.T) {
+		tc := testCase{
+			// Test that if the remote peer is trusted and the header contains
+			// a comma separated list of valid IPs, we get right most one -1 proxiesCount.
+			trustedPeers: localnet,
+			proxiesCount: 1,
+			headerKeys:   []string{XForwardedFor},
+			inputHeaders: map[string]string{
+				XForwardedFor: fmt.Sprintf("%s,%s", publicIP.String(), localhost.String()),
+			},
+			peer:       localhostPeer(),
+			expectedIP: publicIP,
+		}
+		t.Run("unary", func(t *testing.T) {
+			testUnaryServerInterceptor(t, tc)
+		})
+		t.Run("stream", func(t *testing.T) {
+			testStreamServerInterceptor(t, tc)
+		})
+	})
+	t.Run("wrong trusted proxy count with XForwardedFor", func(t *testing.T) {
+		tc := testCase{
+			// Test that if the remote peer is trusted and the header contains
+			// a comma separated list of valid IPs,
+			// we get peer ip as the proxiesCount is wrongly configured
+			trustedPeers: localnet,
+			proxiesCount: 10,
+			headerKeys:   []string{XForwardedFor},
+			inputHeaders: map[string]string{
+				XForwardedFor: fmt.Sprintf("%s,%s", publicIP.String(), localhost.String()),
+			},
+			peer:       localhostPeer(),
+			expectedIP: localhost,
+		}
+		t.Run("unary", func(t *testing.T) {
+			testUnaryServerInterceptor(t, tc)
+		})
+		t.Run("stream", func(t *testing.T) {
+			testStreamServerInterceptor(t, tc)
+		})
+	})
 	t.Run("trusted peer single", func(t *testing.T) {
 		tc := testCase{
 			// Test that if the remote peer is trusted and the header contains