From ebf1086929e77cc1eea68ba2e02a941114b03e6d Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Wed, 24 Jul 2024 08:02:24 -0700 Subject: [PATCH] feat(debug): Log a privacy preserving hash of X-Forwarded-For IP to assist in rate limiting debugging (#148) * Implement Fingerprint in its own file, and basic unit test. * Address PR feedback. * Add seed to prevent exhaustive IP lookup --- go.mod | 2 +- server/fingerprint.go | 118 +++++++++++++++++++++++++++++++++++++ server/fingerprint_test.go | 37 ++++++++++++ server/http_client.go | 22 ++++--- server/request_handler.go | 13 +++- server/server.go | 5 +- 6 files changed, 185 insertions(+), 12 deletions(-) create mode 100644 server/fingerprint.go create mode 100644 server/fingerprint_test.go diff --git a/go.mod b/go.mod index 1df1e68..5564135 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( github.com/alicebob/miniredis v2.5.0+incompatible + github.com/cespare/xxhash/v2 v2.2.0 github.com/ethereum/go-ethereum v1.13.11 github.com/go-redis/redis/v8 v8.11.5 github.com/google/uuid v1.3.0 @@ -17,7 +18,6 @@ require ( github.com/bits-and-blooms/bitset v1.10.0 // indirect github.com/btcsuite/btcd/btcec/v2 v2.2.0 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/consensys/bavard v0.1.13 // indirect github.com/consensys/gnark-crypto v0.12.1 // indirect github.com/crate-crypto/go-kzg-4844 v0.7.0 // indirect diff --git a/server/fingerprint.go b/server/fingerprint.go new file mode 100644 index 0000000..8756481 --- /dev/null +++ b/server/fingerprint.go @@ -0,0 +1,118 @@ +package server + +import ( + "fmt" + "net" + "net/http" + "strings" + "time" + + "github.com/cespare/xxhash/v2" +) + +type Fingerprint uint64 + +// CIDR parsing code taken from https://github.com/tomasen/realip/blob/master/realip.go +// MIT Licensed, Copyright (c) 2018 SHEN SHENG +var ( + privateIpCidrBlocks []*net.IPNet + rfc1918cidrBlockStrings = []string{ + "127.0.0.1/8", // localhost + "10.0.0.0/8", // 24-bit block + "172.16.0.0/12", // 20-bit block + "192.168.0.0/16", // 16-bit block + "169.254.0.0/16", // link local address + "::1/128", // localhost IPv6 + "fc00::/7", // unique local address IPv6 + "fe80::/10", // link local address IPv6 + } +) + +func init() { + privateIpCidrBlocks = make([]*net.IPNet, len(rfc1918cidrBlockStrings)) + for i, maxCidrBlock := range rfc1918cidrBlockStrings { + _, cidr, _ := net.ParseCIDR(maxCidrBlock) + privateIpCidrBlocks[i] = cidr + } +} + +// FingerprintFromRequest returns a fingerprint for the request based on the X-Forwarded-For header +// and a salted timestamp. The fingerprint is used to identify unique users sessions +// over a short period of time, and thus can be used as a key for rate limiting. +// The seed param is additional entropy to make the fingerprint resistant to rainbow table lookups. +// Without the seed a malicious rpc operator could reverse a client IP address to a fingerprint +// by exhausting all possible IP addresses and comparing the resulting fingerprints. +// +// We considered adding the User-Agent header to the fingerprint, but decided +// against it because it would make the fingerprint gameable. Instead, we +// will salt the fingerprint with the current timestamp rounded to the +// latest hour. This will make sure fingerprints rotate every hour so we +// cannot reasonably track user behavior over time. +func FingerprintFromRequest(req *http.Request, at time.Time, seed uint64) (Fingerprint, error) { + // X-Forwarded-For header contains a comma-separated list of IP addresses when + // the request has been forwarded through multiple proxies. For example: + // + // X-Forwarded-For: 2600:8802:4700:bee:d13c:c7fb:8e0f:84ff, 172.70.210.100 + xff, err := getXForwardedForIP(req) + if err != nil { + return 0, err + } + if at.IsZero() { + at = time.Now().UTC() + } + salt := uint64(at.Truncate(time.Hour).Unix()) ^ seed + fingerprintPreimage := fmt.Sprintf("XFF:%s|SALT:%d", xff, salt) + return Fingerprint(xxhash.Sum64String(fingerprintPreimage)), nil +} + +func (f Fingerprint) ToIPv6() net.IP { + // We'll generate a "fake" IPv6 address based on the fingerprint + // We'll use the RFC 3849 documentation prefix (2001:DB8::/32) for this. + // https://datatracker.ietf.org/doc/html/rfc3849 + addr := [16]byte{ + 0: 0x20, + 1: 0x01, + 2: 0x0d, + 3: 0xb8, + 8: byte(f >> 56), + 9: byte(f >> 48), + 10: byte(f >> 40), + 11: byte(f >> 32), + 12: byte(f >> 24), + 13: byte(f >> 16), + 14: byte(f >> 8), + 15: byte(f), + } + return addr[:] +} + +func getXForwardedForIP(r *http.Request) (string, error) { + // gets the left-most non-private IP in the X-Forwarded-For header + xff := r.Header.Get("X-Forwarded-For") + if xff == "" { + return "", fmt.Errorf("no X-Forwarded-For header") + } + ips := strings.Split(xff, ",") + for _, ip := range ips { + if !isPrivateIP(strings.TrimSpace(ip)) { + return ip, nil + } + } + return "", fmt.Errorf("no non-private IP in X-Forwarded-For header") +} + +func isPrivateIP(ip string) bool { + // compare ip to RFC-1918 known private IP ranges + // https://en.wikipedia.org/wiki/Private_network + ipAddr := net.ParseIP(ip) + if ipAddr == nil { + return false + } + + for _, cidr := range privateIpCidrBlocks { + if cidr.Contains(ipAddr) { + return true + } + } + return false +} diff --git a/server/fingerprint_test.go b/server/fingerprint_test.go new file mode 100644 index 0000000..5cf94a1 --- /dev/null +++ b/server/fingerprint_test.go @@ -0,0 +1,37 @@ +package server_test + +import ( + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/flashbots/rpc-endpoint/server" +) + +func TestFingerprint_ToIPv6(t *testing.T) { + seed := uint64(0x4242420000004242) + req, err := http.NewRequest("GET", "http://example.com", nil) + require.NoError(t, err) + + req.Header.Set("X-Forwarded-For", "2600:8802:4700:bee:d13c:c7fb:8e0f:84ff, 172.70.210.100") + fingerprint1, err := server.FingerprintFromRequest( + req, + time.Date(2022, 1, 1, 1, 2, 3, 4, time.UTC), + seed, + ) + require.NoError(t, err) + require.True(t, strings.HasPrefix(fingerprint1.ToIPv6().String(), "2001:db8::")) + + fingerprint2, err := server.FingerprintFromRequest( + req, + time.Date(2022, 1, 1, 2, 3, 4, 5, time.UTC), + seed, + ) + require.NoError(t, err) + require.True(t, strings.HasPrefix(fingerprint2.ToIPv6().String(), "2001:db8::")) + + require.NotEqual(t, fingerprint1, fingerprint2) +} diff --git a/server/http_client.go b/server/http_client.go index 71bb9bc..2a3cfc4 100644 --- a/server/http_client.go +++ b/server/http_client.go @@ -14,16 +14,18 @@ type RPCProxyClient interface { } type rpcProxyClient struct { - logger log.Logger - httpClient http.Client - proxyURL string + logger log.Logger + httpClient http.Client + proxyURL string + fingerprint Fingerprint } -func NewRPCProxyClient(logger log.Logger, proxyURL string, timeoutSeconds int) RPCProxyClient { +func NewRPCProxyClient(logger log.Logger, proxyURL string, timeoutSeconds int, fingerprint Fingerprint) RPCProxyClient { return &rpcProxyClient{ - logger: logger, - httpClient: http.Client{Timeout: time.Second * time.Duration(timeoutSeconds)}, - proxyURL: proxyURL, + logger: logger, + httpClient: http.Client{Timeout: time.Second * time.Duration(timeoutSeconds)}, + proxyURL: proxyURL, + fingerprint: fingerprint, } } @@ -33,6 +35,12 @@ func (n *rpcProxyClient) ProxyRequest(body []byte) (*http.Response, error) { if err != nil { return nil, err } + if n.fingerprint != 0 { + req.Header.Set( + "X-Forwarded-For", + n.fingerprint.ToIPv6().String(), + ) + } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Length", strconv.Itoa(len(body))) diff --git a/server/request_handler.go b/server/request_handler.go index 60a4a97..07723ca 100644 --- a/server/request_handler.go +++ b/server/request_handler.go @@ -8,12 +8,16 @@ import ( "time" "github.com/ethereum/go-ethereum/log" + "github.com/google/uuid" + "golang.org/x/exp/rand" + "github.com/flashbots/rpc-endpoint/application" "github.com/flashbots/rpc-endpoint/database" "github.com/flashbots/rpc-endpoint/types" - "github.com/google/uuid" ) +var seed uint64 = uint64(rand.Int63()) + // RPC request handler for a single/ batch JSON-RPC request type RpcRequestHandler struct { respw *http.ResponseWriter @@ -101,8 +105,13 @@ func (r *RpcRequestHandler) process() { return } + fingerprint, _ := FingerprintFromRequest(r.req, time.Now(), seed) + if fingerprint != 0 { + r.logger = r.logger.New("fingerprint", fingerprint.ToIPv6().String()) + } + // create rpc proxy client for making proxy request - client := NewRPCProxyClient(r.logger, r.defaultProxyUrl, r.proxyTimeoutSeconds) + client := NewRPCProxyClient(r.logger, r.defaultProxyUrl, r.proxyTimeoutSeconds, fingerprint) r.requestRecord.UpdateRequestEntry(r.req, http.StatusOK, "") // Data analytics diff --git a/server/server.go b/server/server.go index 0e985b4..fc9f021 100644 --- a/server/server.go +++ b/server/server.go @@ -22,8 +22,9 @@ import ( "github.com/ethereum/go-ethereum/log" "github.com/alicebob/miniredis" - "github.com/flashbots/rpc-endpoint/types" "github.com/pkg/errors" + + "github.com/flashbots/rpc-endpoint/types" ) var Now = time.Now // used to mock time in tests @@ -114,7 +115,7 @@ func NewRpcEndPointServer(cfg Configuration) (*RpcEndPointServer, error) { func fetchNetworkIDBytes(cfg Configuration) ([]byte, error) { - cl := NewRPCProxyClient(cfg.Logger, cfg.ProxyUrl, cfg.ProxyTimeoutSeconds) + cl := NewRPCProxyClient(cfg.Logger, cfg.ProxyUrl, cfg.ProxyTimeoutSeconds, 0) _req := types.NewJsonRpcRequest(1, "net_version", []interface{}{}) jsonData, err := json.Marshal(_req)