From 757544f213574186a46ee9bf6c8a5be6b06c1b7d Mon Sep 17 00:00:00 2001 From: Yury Gargay Date: Mon, 12 Feb 2024 20:22:51 +0100 Subject: [PATCH] Extend realip interceptors with ip selection based on proxy count and list (#695) * Extend 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 preferably pick `TrustedProxies` or `TrustedProxiesCount`. * Use functional options to configure the interceptor * Fix linter --- interceptors/realip/doc.go | 28 +++++-- interceptors/realip/examples_test.go | 24 +++++- interceptors/realip/options.go | 59 +++++++++++++++ interceptors/realip/realip.go | 60 ++++++++++++--- interceptors/realip/realip_test.go | 109 +++++++++++++++++++++++++-- 5 files changed, 252 insertions(+), 28 deletions(-) create mode 100644 interceptors/realip/options.go diff --git a/interceptors/realip/doc.go b/interceptors/realip/doc.go index 19f3d52f3..7930dae32 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: +`,`. 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/examples_test.go b/interceptors/realip/examples_test.go index 3caf0d01c..12a7aa320 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/options.go b/interceptors/realip/options.go new file mode 100644 index 000000000..5f6213464 --- /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 41b2cde0f..bd0f2d20d 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, diff --git a/interceptors/realip/realip_test.go b/interceptors/realip/realip_test.go index 9666ed527..c5384ef3e 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