From f9a4acf31f4710d2b790cbfb412771da42e17bc8 Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Sat, 8 Aug 2026 20:00:06 +0700 Subject: [PATCH] fix(server): block private IPs on custom proxy and mempoolrpc URLs The intentional ?url= BYO-proxy feature accepted arbitrary http(s) targets with no host validation, so loopback/link-local/RFC1918/CGNAT addresses were reachable and response bodies reflected. Validate scheme and resolved addresses before proxying; re-check redirects; apply the same gate to mempoolrpc. Signed-off-by: Sasha Mitchell --- server/http_client.go | 7 ++- server/outbound_url.go | 103 +++++++++++++++++++++++++++++++++ server/outbound_url_test.go | 44 ++++++++++++++ server/request_handler.go | 8 ++- server/request_handler_test.go | 2 +- server/url_params.go | 3 + 6 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 server/outbound_url.go create mode 100644 server/outbound_url_test.go diff --git a/server/http_client.go b/server/http_client.go index 2a3cfc4..d1d8fb4 100644 --- a/server/http_client.go +++ b/server/http_client.go @@ -22,8 +22,11 @@ type rpcProxyClient struct { 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)}, + logger: logger, + httpClient: http.Client{ + Timeout: time.Second * time.Duration(timeoutSeconds), + CheckRedirect: validateRedirectURL, + }, proxyURL: proxyURL, fingerprint: fingerprint, } diff --git a/server/outbound_url.go b/server/outbound_url.go new file mode 100644 index 0000000..a5b6af1 --- /dev/null +++ b/server/outbound_url.go @@ -0,0 +1,103 @@ +package server + +import ( + "fmt" + "net" + "net/http" + "net/url" + "strings" +) + +// Blocked outbound ranges for user-supplied proxy / mempool RPC URLs. +// Broader than XFF fingerprint private ranges (includes CGNAT / unspecified). +var blockedOutboundCidrs []*net.IPNet + +func init() { + cidrs := []string{ + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.168.0.0/16", + "::/128", + "::1/128", + "fc00::/7", + "fe80::/10", + } + blockedOutboundCidrs = make([]*net.IPNet, 0, len(cidrs)) + for _, c := range cidrs { + _, network, err := net.ParseCIDR(c) + if err != nil { + panic(err) + } + blockedOutboundCidrs = append(blockedOutboundCidrs, network) + } +} + +func isBlockedOutboundIP(ip net.IP) bool { + if ip == nil { + return true + } + for _, network := range blockedOutboundCidrs { + if network.Contains(ip) { + return true + } + } + return false +} + +// ValidateOutboundRPCURL ensures a user-supplied RPC proxy URL uses http(s) +// and does not resolve to a private / link-local / loopback address. +func ValidateOutboundRPCURL(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("empty url") + } + parsed, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid url: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("url scheme must be http or https") + } + host := parsed.Hostname() + if host == "" { + return fmt.Errorf("url missing host") + } + if strings.EqualFold(host, "localhost") { + return fmt.Errorf("private or link-local address not allowed") + } + + if ip := net.ParseIP(host); ip != nil { + if isBlockedOutboundIP(ip) { + return fmt.Errorf("private or link-local address not allowed") + } + return nil + } + + ips, err := net.LookupIP(host) + if err != nil { + return fmt.Errorf("failed to resolve host: %w", err) + } + if len(ips) == 0 { + return fmt.Errorf("failed to resolve host") + } + for _, ip := range ips { + if isBlockedOutboundIP(ip) { + return fmt.Errorf("private or link-local address not allowed") + } + } + return nil +} + +func validateRedirectURL(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + if req == nil || req.URL == nil { + return fmt.Errorf("invalid redirect") + } + return ValidateOutboundRPCURL(req.URL.String()) +} diff --git a/server/outbound_url_test.go b/server/outbound_url_test.go new file mode 100644 index 0000000..6526869 --- /dev/null +++ b/server/outbound_url_test.go @@ -0,0 +1,44 @@ +package server + +import ( + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateOutboundRPCURL(t *testing.T) { + t.Parallel() + + require.NoError(t, ValidateOutboundRPCURL("https://example.com")) + require.NoError(t, ValidateOutboundRPCURL("http://1.1.1.1:8545")) + + require.Error(t, ValidateOutboundRPCURL("http://127.0.0.1/")) + require.Error(t, ValidateOutboundRPCURL("http://169.254.169.254/latest/meta-data/")) + require.Error(t, ValidateOutboundRPCURL("http://10.0.0.5")) + require.Error(t, ValidateOutboundRPCURL("http://192.168.1.1")) + require.Error(t, ValidateOutboundRPCURL("http://172.16.0.1")) + require.Error(t, ValidateOutboundRPCURL("http://100.64.0.1")) + require.Error(t, ValidateOutboundRPCURL("http://localhost:8545")) + require.Error(t, ValidateOutboundRPCURL("file:///etc/passwd")) + require.Error(t, ValidateOutboundRPCURL("ftp://example.com")) + require.Error(t, ValidateOutboundRPCURL("not a url")) +} + +func TestValidateRedirectURL(t *testing.T) { + t.Parallel() + + req := &http.Request{URL: mustURL(t, "http://127.0.0.1/")} + require.Error(t, validateRedirectURL(req, nil)) + + req = &http.Request{URL: mustURL(t, "https://example.com/rpc")} + require.NoError(t, validateRedirectURL(req, nil)) +} + +func mustURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + require.NoError(t, err) + return u +} diff --git a/server/request_handler.go b/server/request_handler.go index 2cb994c..eb8bc83 100644 --- a/server/request_handler.go +++ b/server/request_handler.go @@ -112,9 +112,15 @@ func (r *RpcRequestHandler) process() { referer := r.req.Header.Get("Referer") // If users specify a proxy url in their rpc endpoint they can have their requests proxied to that endpoint instead of Infura - // e.g. https://rpc.flashbots.net?url=http://RPC-ENDPOINT.COM + // e.g. https://rpc.flashbots.net?url=https://RPC-ENDPOINT.COM customProxyUrl, ok := r.req.URL.Query()["url"] if ok && len(customProxyUrl[0]) > 1 { + if err := ValidateOutboundRPCURL(customProxyUrl[0]); err != nil { + r.requestRecord.UpdateRequestEntry(r.req, http.StatusBadRequest, err.Error()) + r.logger.Warn("[process] Rejected custom url", "url", customProxyUrl[0], "error", err) + (*r.respw).WriteHeader(http.StatusBadRequest) + return + } metrics.UrlParamUsageInc() r.defaultProxyUrl = customProxyUrl[0] r.logger.Info("[process] Using custom url", "url", r.defaultProxyUrl) diff --git a/server/request_handler_test.go b/server/request_handler_test.go index d454194..4725075 100644 --- a/server/request_handler_test.go +++ b/server/request_handler_test.go @@ -96,7 +96,7 @@ func TestGetEffectiveParametersHeaderNoPreset(t *testing.T) { func TestRpcRequestHandler_UrlParam(t *testing.T) { wrec := httptest.NewRecorder() - req := httptest.NewRequest("POST", "/?url=http://mock.url", nil) + req := httptest.NewRequest("POST", "/?url=https://example.com", nil) metrics.UrlParamUsage.Set(0) diff --git a/server/url_params.go b/server/url_params.go index 1a1e1f0..cc984ca 100644 --- a/server/url_params.go +++ b/server/url_params.go @@ -207,6 +207,9 @@ func ExtractParametersFromUrl(reqUrl *url.URL, allBuilders []string) (params URL return params, ErrIncorrectMempoolURL } parsedUrl.Scheme = "https" + if err := ValidateOutboundRPCURL(parsedUrl.String()); err != nil { + return params, ErrIncorrectMempoolURL + } params.pref.Privacy.MempoolRPC = parsedUrl.String() } blockRange := normalizedQuery["blockrange"]