Skip to content

Commit

Permalink
feat(debug): Log a privacy preserving hash of X-Forwarded-For IP to a…
Browse files Browse the repository at this point in the history
…ssist 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
  • Loading branch information
ryanschneider authored Jul 24, 2024
1 parent 36fbf1d commit ebf1086
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 12 deletions.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
118 changes: 118 additions & 0 deletions server/fingerprint.go
Original file line number Diff line number Diff line change
@@ -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
}
37 changes: 37 additions & 0 deletions server/fingerprint_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
22 changes: 15 additions & 7 deletions server/http_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand All @@ -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)))
Expand Down
13 changes: 11 additions & 2 deletions server/request_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit ebf1086

Please sign in to comment.