Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(debug): Log a privacy preserving hash of IP and UserAgent to assist in rate limiting debugging #148

Merged
merged 8 commits into from
Jul 24, 2024
Merged
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 {
ryanschneider marked this conversation as resolved.
Show resolved Hide resolved
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
Loading