From f35f04747c8d812281a0fb02d846f18dea045d4e Mon Sep 17 00:00:00 2001 From: MadsRC Date: Wed, 13 Dec 2023 06:03:02 +0100 Subject: [PATCH] Middleware for determining the real ip of the client (#682) * feat: added realip middleware * refactor for netip * set proper case * update header values * force lowercase when checking header --- interceptors/realip/doc.go | 95 +++++++ interceptors/realip/examples_test.go | 43 ++++ interceptors/realip/realip.go | 136 ++++++++++ interceptors/realip/realip_test.go | 368 +++++++++++++++++++++++++++ 4 files changed, 642 insertions(+) create mode 100644 interceptors/realip/doc.go create mode 100644 interceptors/realip/examples_test.go create mode 100644 interceptors/realip/realip.go create mode 100644 interceptors/realip/realip_test.go diff --git a/interceptors/realip/doc.go b/interceptors/realip/doc.go new file mode 100644 index 000000000..19f3d52f3 --- /dev/null +++ b/interceptors/realip/doc.go @@ -0,0 +1,95 @@ +// Copyright (c) The go-grpc-middleware Authors. +// Licensed under the Apache License 2.0. + +/* +Package realip is a middleware that extracts the real IP of requests based on +header values. + +The real IP is subsequently placed inside the context of each request and can +be retrieved using the [FromContext] function. + +The middleware is designed to work with gRPC servers serving clients over +TCP/IP connections. If no headers are found, the middleware will return the +remote peer address as the real IP. If remote peer address is not a TCP/IP +address, the middleware will return nil as the real IP. + +Headers provided by clients in the request will be searched for in the order +of the list provided to the middleware. The first header that contains a valid +IP address will be used as the real IP. + +Comma separated headers are supported. The last, rightmost, IP address in the +header will be used as the real IP. + +# Security + +There are 2 main security concerns when deriving the real IP from request +headers: + + 1. Risk of spoofing the real IP by setting a header value. + 2. Risk of injecting a header value that causes a denial of service. + +To mitigate the risk of spoofing, the middleware introduces the concept of +"trusted peers". Trusted peers are defined as a list of IP networks that are +verified by the gRPC server operator to be trusted. If the peer address is found +to be within one of the trusted networks, the middleware will attempt to extract +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 +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. + +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. + +# Individual IP addresses as trusted peers + +When creating the list of trusted peers, it is possible to specify individual IP +addresses. This is useful when your proxy or load balancer has a set of +well-known addresses. + +The following example shows how to specify individual IP addresses as trusted +peers: + + trusted := []net.IPNet{ + {IP: net.IPv4(192, 168, 0, 1), Mask: net.IPv4Mask(255, 255, 255, 255)}, + {IP: net.IPv4(192, 168, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + } + +In the above example, the middleware will only attempt to extract the real IP +from the request headers if the peer address is either 192.168.0.1 or +192.168.0.2. + +# Headers + +Headers to search for are specified as a list of strings when creating the +middleware. The middleware will search for the headers in the order specified +and use the first header that contains a valid IP address as the real IP. + +The following are examples of headers that may contain the real IP: + + - X-Forwarded-For: This header is set by proxies and contains a comma + separated list of IP addresses. Each proxy that forwards the request will + append the real IP to the header value. + - X-Real-IP: This header is set by NGINX and contains the real IP as a string + containing a single IP address. + - Forwarded-For: Header defined by RFC7239. This header is set by proxies and + contains the real IP as a string containing a single IP address. Please note + that the obfuscated identifier from section 6.3 of RFC7239, and that the + unknown identifier from section 6.2 of RFC7239 are not supported. + - True-Client-IP: This header is set by Cloudflare and contains the real IP + as a string containing a single IP address. + +# Usage + +Please see examples for simple examples of use. +*/ +package realip diff --git a/interceptors/realip/examples_test.go b/interceptors/realip/examples_test.go new file mode 100644 index 000000000..3caf0d01c --- /dev/null +++ b/interceptors/realip/examples_test.go @@ -0,0 +1,43 @@ +// Copyright (c) The go-grpc-middleware Authors. +// Licensed under the Apache License 2.0. + +package realip_test + +import ( + "net/netip" + + "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/realip" + "google.golang.org/grpc" +) + +// Simple example of a unary server initialization code. +func ExampleUnaryServerInterceptor() { + // Define list of trusted peers from which we accept forwarded-for and + // real-ip headers. + trustedPeers := []netip.Prefix{ + netip.MustParsePrefix("127.0.0.1/32"), + } + // Define headers to look for in the incoming request. + headers := []string{realip.XForwardedFor, realip.XRealIp} + _ = grpc.NewServer( + grpc.ChainUnaryInterceptor( + realip.UnaryServerInterceptor(trustedPeers, headers), + ), + ) +} + +// Simple example of a streaming server initialization code. +func ExampleStreamServerInterceptor() { + // Define list of trusted peers from which we accept forwarded-for and + // real-ip headers. + trustedPeers := []netip.Prefix{ + netip.MustParsePrefix("127.0.0.1/32"), + } + // Define headers to look for in the incoming request. + headers := []string{realip.XForwardedFor, realip.XRealIp} + _ = grpc.NewServer( + grpc.ChainStreamInterceptor( + realip.StreamServerInterceptor(trustedPeers, headers), + ), + ) +} diff --git a/interceptors/realip/realip.go b/interceptors/realip/realip.go new file mode 100644 index 000000000..8d5a0214e --- /dev/null +++ b/interceptors/realip/realip.go @@ -0,0 +1,136 @@ +// Copyright (c) The go-grpc-middleware Authors. +// Licensed under the Apache License 2.0. + +package realip + +import ( + "context" + "net" + "net/netip" + "strings" + + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" +) + +// XRealIp, XForwardedFor and TrueClientIp are header keys +// used to extract the real client IP from the request. They represent common +// conventions for identifying the originating IP address of a client connecting +// through proxies or load balancers. +const ( + XRealIp = "X-Real-IP" + XForwardedFor = "X-Forwarded-For" + TrueClientIp = "True-Client-IP" +) + +var noIP = netip.Addr{} + +type realipKey struct{} + +// FromContext extracts the real client IP from the context. +// It returns the IP and a boolean indicating if it was present. +func FromContext(ctx context.Context) (netip.Addr, bool) { + ip, ok := ctx.Value(realipKey{}).(netip.Addr) + return ip, ok +} + +func remotePeer(ctx context.Context) net.Addr { + pr, ok := peer.FromContext(ctx) + if !ok { + return nil + } + return pr.Addr +} + +func ipInNets(ip netip.Addr, nets []netip.Prefix) bool { + for _, n := range nets { + if n.Contains(ip) { + return true + } + } + return false +} + +func getHeader(ctx context.Context, key string) string { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return "" + } + + if md[strings.ToLower(key)] == nil { + return "" + } + + return md[strings.ToLower(key)][0] +} + +func ipFromHeaders(ctx context.Context, headers []string) netip.Addr { + for _, header := range headers { + a := strings.Split(getHeader(ctx, header), ",") + h := strings.TrimSpace(a[len(a)-1]) + ip, err := netip.ParseAddr(h) + if err == nil { + return ip + } + } + return noIP +} + +func getRemoteIP(ctx context.Context, trustedPeers []netip.Prefix, headers []string) netip.Addr { + pr := remotePeer(ctx) + if pr == nil { + return noIP + } + + ip, err := netip.ParseAddr(strings.Split(pr.String(), ":")[0]) + if err != nil { + return noIP + } + if len(trustedPeers) == 0 || !ipInNets(ip, trustedPeers) { + return ip + } + if ip := ipFromHeaders(ctx, headers); ip != noIP { + return ip + } + // No ip from the headers, return the peer ip. + return ip +} + +type serverStream struct { + grpc.ServerStream + ctx context.Context +} + +func (s *serverStream) Context() context.Context { + return s.ctx +} + +// 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. +func UnaryServerInterceptor(trustedPeers []netip.Prefix, headers []string) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + ip := getRemoteIP(ctx, trustedPeers, headers) + if ip != noIP { + ctx = context.WithValue(ctx, realipKey{}, ip) + } + return handler(ctx, req) + } +} + +// 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. +func StreamServerInterceptor(trustedPeers []netip.Prefix, headers []string) grpc.StreamServerInterceptor { + return func(srv any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + ip := getRemoteIP(stream.Context(), trustedPeers, headers) + if ip != noIP { + return handler(srv, &serverStream{ + ServerStream: stream, + ctx: context.WithValue(stream.Context(), realipKey{}, ip), + }) + } + return handler(srv, stream) + } +} diff --git a/interceptors/realip/realip_test.go b/interceptors/realip/realip_test.go new file mode 100644 index 000000000..4eeac5129 --- /dev/null +++ b/interceptors/realip/realip_test.go @@ -0,0 +1,368 @@ +// Copyright (c) The go-grpc-middleware Authors. +// Licensed under the Apache License 2.0. + +package realip + +import ( + "context" + "fmt" + "net" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" +) + +var ( + localnet []netip.Prefix = []netip.Prefix{ + netip.MustParsePrefix("127.0.0.1/8"), + } + + privatenet []netip.Prefix = []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.168.0.0/16"), + } + + privateIP netip.Addr = netip.MustParseAddr("192.168.0.1") + publicIP netip.Addr = netip.MustParseAddr("8.8.8.8") + localhost netip.Addr = netip.MustParseAddr("127.0.0.1") +) + +func localhostPeer() *peer.Peer { + return &peer.Peer{ + Addr: &net.TCPAddr{ + IP: net.ParseIP(localhost.String()), + }, + } +} + +func publicPeer() *peer.Peer { + return &peer.Peer{ + Addr: &net.TCPAddr{ + IP: net.ParseIP(publicIP.String()), + }, + } +} + +func privatePeer() *peer.Peer { + return &peer.Peer{ + Addr: &net.TCPAddr{ + IP: net.ParseIP(privateIP.String()), + }, + } +} + +type testCase struct { + trustedPeers []netip.Prefix + headerKeys []string + inputHeaders map[string]string + peer *peer.Peer + expectedIP netip.Addr +} + +func testUnaryServerInterceptor(t *testing.T, c testCase) { + interceptor := UnaryServerInterceptor(c.trustedPeers, c.headerKeys) + handler := func(ctx context.Context, req any) (any, error) { + ip, _ := FromContext(ctx) + + assert.Equal(t, c.expectedIP, ip) + return nil, nil + } + info := &grpc.UnaryServerInfo{ + FullMethod: "FakeMethod", + } + ctx := context.Background() + if c.peer != nil { + ctx = peer.NewContext(ctx, c.peer) + } + if c.inputHeaders != nil { + md := metadata.New(c.inputHeaders) + ctx = metadata.NewIncomingContext(ctx, md) + } + + resp, err := interceptor(ctx, nil, info, handler) + assert.Nil(t, resp) + assert.NoError(t, err) +} + +func testStreamServerInterceptor(t *testing.T, c testCase) { + interceptor := StreamServerInterceptor(c.trustedPeers, c.headerKeys) + handler := func(srv any, stream grpc.ServerStream) error { + ip, _ := FromContext(stream.Context()) + + assert.Equal(t, c.expectedIP, ip) + return nil + } + info := &grpc.StreamServerInfo{ + FullMethod: "FakeMethod", + } + ctx := context.Background() + if c.peer != nil { + ctx = peer.NewContext(ctx, c.peer) + } + if c.inputHeaders != nil { + md := metadata.New(c.inputHeaders) + ctx = metadata.NewIncomingContext(ctx, md) + } + + err := interceptor(nil, &serverStream{ctx: ctx}, info, handler) + assert.NoError(t, err) +} + +func TestInterceptor(t *testing.T) { + t.Run("no peer", func(t *testing.T) { + tc := testCase{ + // Test that if there is no peer, we don't get an IP. + trustedPeers: localnet, + headerKeys: []string{XForwardedFor}, + inputHeaders: map[string]string{ + XForwardedFor: localhost.String(), + }, + peer: nil, + expectedIP: netip.Addr{}, + } + t.Run("unary", func(t *testing.T) { + testUnaryServerInterceptor(t, tc) + }) + t.Run("stream", func(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 + // a comma separated list of valid IPs, we get right most one. + trustedPeers: localnet, + headerKeys: []string{XForwardedFor}, + inputHeaders: map[string]string{ + XForwardedFor: fmt.Sprintf("%s,%s", localhost.String(), publicIP.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 peer single", func(t *testing.T) { + tc := testCase{ + // Test that if the remote peer is trusted and the header contains + // a single valid IP, we get that IP. + trustedPeers: localnet, + headerKeys: []string{XRealIp}, + inputHeaders: map[string]string{ + XRealIp: privateIP.String(), + }, + peer: localhostPeer(), + expectedIP: privateIP, + } + t.Run("unary", func(t *testing.T) { + testUnaryServerInterceptor(t, tc) + }) + t.Run("stream", func(t *testing.T) { + testStreamServerInterceptor(t, tc) + }) + }) + t.Run("trusted peer multiple", func(t *testing.T) { + tc := testCase{ + // Test that if the trusted peers list is larger than 1 network and + // the remote peer is in the third network, we get the right IP. + trustedPeers: privatenet, + headerKeys: []string{TrueClientIp}, + inputHeaders: map[string]string{ + TrueClientIp: publicIP.String(), + }, + peer: privatePeer(), + expectedIP: publicIP, + } + t.Run("unary", func(t *testing.T) { + testUnaryServerInterceptor(t, tc) + }) + t.Run("stream", func(t *testing.T) { + testStreamServerInterceptor(t, tc) + }) + }) + t.Run("untrusted peer single", func(t *testing.T) { + tc := testCase{ + // Test that if the remote peer is not trusted and the header + // contains a single valid IP, we get that the peer IP. + trustedPeers: localnet, + headerKeys: []string{XRealIp}, + inputHeaders: map[string]string{ + XRealIp: privateIP.String(), + }, + peer: publicPeer(), + 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 peer multiple headers", func(t *testing.T) { + tc := testCase{ + // Test that if the peer is trusted and several headers are + // provided, the interceptor reads the IP from the first header in + // the list. + trustedPeers: localnet, + headerKeys: []string{XRealIp, TrueClientIp}, + inputHeaders: map[string]string{ + XRealIp: privateIP.String(), + TrueClientIp: publicIP.String(), + }, + peer: localhostPeer(), + expectedIP: privateIP, + } + t.Run("unary", func(t *testing.T) { + testUnaryServerInterceptor(t, tc) + }) + t.Run("stream", func(t *testing.T) { + testStreamServerInterceptor(t, tc) + }) + }) + t.Run("trusted peer multiple header configured single provided", func(t *testing.T) { + tc := testCase{ + // Test that if the peer is trusted and several headers are + // configured, but only one is provided, the interceptor reads the + // IP from the provided header. + trustedPeers: localnet, + headerKeys: []string{XRealIp, TrueClientIp, XForwardedFor}, + inputHeaders: map[string]string{ + TrueClientIp: publicIP.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 peer multiple header configured none provided", func(t *testing.T) { + tc := testCase{ + // Test that if the peer is trusted and several headers are, but no + // header is provided, the interceptor reads the IP from the peer. + // + // This indicates that the proxy is not configured to forward the + // IP. Using the peer IP is better than nothing. + trustedPeers: localnet, + headerKeys: []string{XRealIp, TrueClientIp, XForwardedFor}, + 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("untrusted peer multiple headers", func(t *testing.T) { + tc := testCase{ + // Test that if the peer is not trusted, but several headers are + // provided, the interceptor reads the IP from peer. + trustedPeers: nil, + inputHeaders: map[string]string{ + XRealIp: privateIP.String(), + TrueClientIp: localhost.String(), + }, + peer: publicPeer(), + expectedIP: publicIP, + } + t.Run("unary", func(t *testing.T) { + testUnaryServerInterceptor(t, tc) + }) + t.Run("stream", func(t *testing.T) { + testStreamServerInterceptor(t, tc) + }) + }) + t.Run("untrusted peer multiple header configured single provided", func(t *testing.T) { + tc := testCase{ + // Test that if the peer is not trusted and several headers are + // configured, but only one is provided, the interceptor reads the + // IP from the peer. + // + // This is because the peer is untrusted, and as such we cannot + // trust the headers. + trustedPeers: nil, + headerKeys: []string{XRealIp, TrueClientIp, XForwardedFor}, + inputHeaders: map[string]string{ + TrueClientIp: publicIP.String(), + }, + peer: publicPeer(), + 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 peer malformed header", func(t *testing.T) { + tc := testCase{ + // Test that if the peer is trusted, but the provided headers + // contain malformed IP addresses, the interceptor reads the IP + // from the peer. + trustedPeers: localnet, + headerKeys: []string{XRealIp, TrueClientIp, XForwardedFor}, + inputHeaders: map[string]string{ + TrueClientIp: "malformed", + }, + 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("unix", func(t *testing.T) { + tc := testCase{ + trustedPeers: localnet, + headerKeys: []string{XRealIp}, + peer: &peer.Peer{ + Addr: &net.UnixAddr{Name: "unix", Net: "unix"}, + }, + expectedIP: netip.Addr{}, + } + t.Run("unary", func(t *testing.T) { + testUnaryServerInterceptor(t, tc) + }) + t.Run("stream", func(t *testing.T) { + testStreamServerInterceptor(t, tc) + }) + }) + t.Run("header casing", func(t *testing.T) { + tc := testCase{ + // Test that header casing is ignored. + trustedPeers: localnet, + headerKeys: []string{XRealIp}, + inputHeaders: map[string]string{ + "X-Real-IP": privateIP.String(), + }, + peer: localhostPeer(), + expectedIP: privateIP, + } + t.Run("unary", func(t *testing.T) { + testUnaryServerInterceptor(t, tc) + }) + t.Run("stream", func(t *testing.T) { + testStreamServerInterceptor(t, tc) + }) + }) +}