Skip to content

Commit

Permalink
Extend realip interceptors with ip selection based on proxy count and…
Browse files Browse the repository at this point in the history
… 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
  • Loading branch information
surik authored Feb 12, 2024
1 parent 3782759 commit 757544f
Show file tree
Hide file tree
Showing 5 changed files with 252 additions and 28 deletions.
28 changes: 21 additions & 7 deletions interceptors/realip/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions interceptors/realip/examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,49 @@ 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{
netip.MustParsePrefix("127.0.0.1/32"),
}
// 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{
netip.MustParsePrefix("127.0.0.1/32"),
}
// 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...),
),
)
}
59 changes: 59 additions & 0 deletions interceptors/realip/options.go
Original file line number Diff line number Diff line change
@@ -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
}
}
60 changes: 51 additions & 9 deletions interceptors/realip/realip.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -111,22 +133,42 @@ 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)
}
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.
// 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,
Expand Down
109 changes: 101 additions & 8 deletions interceptors/realip/realip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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())

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down

0 comments on commit 757544f

Please sign in to comment.