diff --git a/core/services/gateway/network/httpclient.go b/core/services/gateway/network/httpclient.go index 52130c8d069..18f34118300 100644 --- a/core/services/gateway/network/httpclient.go +++ b/core/services/gateway/network/httpclient.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "github.com/doyensec/safeurl" + "github.com/smartcontractkit/chainlink-common/pkg/logger" ) @@ -19,6 +21,28 @@ type HTTPClient interface { type HTTPClientConfig struct { MaxResponseBytes uint32 DefaultTimeout time.Duration + BlockedIPs []string + BlockedIPsCIDR []string + AllowedPorts []int + AllowedSchemes []string +} + +var ( + defaultAllowedPorts = []int{80, 443} + defaultAllowedSchemes = []string{"http", "https"} +) + +func (c *HTTPClientConfig) ApplyDefaults() { + if len(c.AllowedPorts) == 0 { + c.AllowedPorts = defaultAllowedPorts + } + + if len(c.AllowedSchemes) == 0 { + c.AllowedSchemes = defaultAllowedSchemes + } + + // safeurl automatically blocks internal IPs so no need + // to set defaults here. } type HTTPRequest struct { @@ -35,7 +59,7 @@ type HTTPResponse struct { } type httpClient struct { - client *http.Client + client *safeurl.WrappedClient config HTTPClientConfig lggr logger.Logger } @@ -43,13 +67,20 @@ type httpClient struct { // NewHTTPClient creates a new NewHTTPClient // As of now, the client does not support TLS configuration but may be extended in the future func NewHTTPClient(config HTTPClientConfig, lggr logger.Logger) (HTTPClient, error) { + config.ApplyDefaults() + safeConfig := safeurl. + GetConfigBuilder(). + SetTimeout(config.DefaultTimeout). + SetAllowedPorts(config.AllowedPorts...). + SetAllowedSchemes(config.AllowedSchemes...). + SetBlockedIPs(config.BlockedIPs...). + SetBlockedIPsCIDR(config.BlockedIPsCIDR...). + Build() + return &httpClient{ config: config, - client: &http.Client{ - Timeout: config.DefaultTimeout, - Transport: http.DefaultTransport, - }, - lggr: lggr, + client: safeurl.Client(safeConfig), + lggr: lggr, }, nil } diff --git a/core/services/gateway/network/httpclient_test.go b/core/services/gateway/network/httpclient_test.go index 2f4cc448ef5..f6e769066a7 100644 --- a/core/services/gateway/network/httpclient_test.go +++ b/core/services/gateway/network/httpclient_test.go @@ -1,16 +1,18 @@ -package network_test +package network import ( "context" "net/http" "net/http/httptest" + "net/url" + "strconv" "testing" "time" + "github.com/doyensec/safeurl" "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink-common/pkg/logger" - "github.com/smartcontractkit/chainlink/v2/core/services/gateway/network" ) func TestHTTPClient_Send(t *testing.T) { @@ -18,20 +20,18 @@ func TestHTTPClient_Send(t *testing.T) { // Setup the test environment lggr := logger.Test(t) - config := network.HTTPClientConfig{ + config := HTTPClientConfig{ MaxResponseBytes: 1024, DefaultTimeout: 5 * time.Second, } - client, err := network.NewHTTPClient(config, lggr) - require.NoError(t, err) // Define test cases tests := []struct { name string setupServer func() *httptest.Server - request network.HTTPRequest + request HTTPRequest expectedError error - expectedResp *network.HTTPResponse + expectedResp *HTTPResponse }{ { name: "successful request", @@ -42,7 +42,7 @@ func TestHTTPClient_Send(t *testing.T) { require.NoError(t, err2) })) }, - request: network.HTTPRequest{ + request: HTTPRequest{ Method: "GET", URL: "/", Headers: map[string]string{}, @@ -50,7 +50,7 @@ func TestHTTPClient_Send(t *testing.T) { Timeout: 2 * time.Second, }, expectedError: nil, - expectedResp: &network.HTTPResponse{ + expectedResp: &HTTPResponse{ StatusCode: http.StatusOK, Headers: map[string]string{"Content-Length": "7"}, Body: []byte("success"), @@ -66,7 +66,7 @@ func TestHTTPClient_Send(t *testing.T) { require.NoError(t, err2) })) }, - request: network.HTTPRequest{ + request: HTTPRequest{ Method: "GET", URL: "/", Headers: map[string]string{}, @@ -85,7 +85,7 @@ func TestHTTPClient_Send(t *testing.T) { require.NoError(t, err2) })) }, - request: network.HTTPRequest{ + request: HTTPRequest{ Method: "GET", URL: "/", Headers: map[string]string{}, @@ -93,7 +93,7 @@ func TestHTTPClient_Send(t *testing.T) { Timeout: 2 * time.Second, }, expectedError: nil, - expectedResp: &network.HTTPResponse{ + expectedResp: &HTTPResponse{ StatusCode: http.StatusInternalServerError, Headers: map[string]string{"Content-Length": "5"}, Body: []byte("error"), @@ -108,7 +108,7 @@ func TestHTTPClient_Send(t *testing.T) { require.NoError(t, err2) })) }, - request: network.HTTPRequest{ + request: HTTPRequest{ Method: "GET", URL: "/", Headers: map[string]string{}, @@ -126,6 +126,26 @@ func TestHTTPClient_Send(t *testing.T) { server := tt.setupServer() defer server.Close() + u, err := url.Parse(server.URL) + require.NoError(t, err) + + hostname, port := u.Hostname(), u.Port() + portInt, err := strconv.ParseInt(port, 10, 32) + require.NoError(t, err) + + safeConfig := safeurl. + GetConfigBuilder(). + SetTimeout(config.DefaultTimeout). + SetAllowedIPs(hostname). + SetAllowedPorts(int(portInt)). + Build() + + client := &httpClient{ + config: config, + client: safeurl.Client(safeConfig), + lggr: lggr, + } + tt.request.URL = server.URL + tt.request.URL resp, err := client.Send(context.Background(), tt.request) @@ -145,3 +165,100 @@ func TestHTTPClient_Send(t *testing.T) { }) } } + +func TestHTTPClient_BlocksUnallowed(t *testing.T) { + t.Parallel() + + // Setup the test environment + lggr := logger.Test(t) + config := HTTPClientConfig{ + MaxResponseBytes: 1024, + DefaultTimeout: 5 * time.Second, + } + + client, err := NewHTTPClient(config, lggr) + require.NoError(t, err) + + // Define test cases + tests := []struct { + name string + request HTTPRequest + expectedError string + }{ + { + name: "blocked port", + request: HTTPRequest{ + Method: "GET", + URL: "http://127.0.0.1:8080", + Headers: map[string]string{}, + Body: nil, + Timeout: 2 * time.Second, + }, + expectedError: "port: 8080 not found in allowlist", + }, + { + name: "blocked scheme", + request: HTTPRequest{ + Method: "GET", + URL: "file://127.0.0.1", + Headers: map[string]string{}, + Body: nil, + Timeout: 2 * time.Second, + }, + expectedError: "scheme: file not found in allowlist", + }, + { + name: "explicitly blocked IP", + request: HTTPRequest{ + Method: "GET", + URL: "http://169.254.0.1", + Headers: map[string]string{}, + Body: nil, + Timeout: 2 * time.Second, + }, + expectedError: "ip: 169.254.0.1 not found in allowlist", + }, + { + name: "explicitly blocked IP - internal network", + request: HTTPRequest{ + Method: "GET", + URL: "http://169.254.0.1/endpoint", + Headers: map[string]string{}, + Body: nil, + Timeout: 2 * time.Second, + }, + expectedError: "ip: 169.254.0.1 not found in allowlist", + }, + { + name: "explicitly blocked IP - localhost", + request: HTTPRequest{ + Method: "GET", + URL: "http://127.0.0.1/endpoint", + Headers: map[string]string{}, + Body: nil, + Timeout: 2 * time.Second, + }, + expectedError: "ip: 127.0.0.1 not found in allowlist", + }, + { + name: "explicitly blocked IP - current network", + request: HTTPRequest{ + Method: "GET", + URL: "http://0.0.0.0/endpoint", + Headers: map[string]string{}, + Body: nil, + Timeout: 2 * time.Second, + }, + expectedError: "ip: 0.0.0.0 not found in allowlist", + }, + } + + // Execute test cases + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := client.Send(context.Background(), tt.request) + require.Error(t, err) + require.ErrorContains(t, err, tt.expectedError) + }) + } +} diff --git a/go.mod b/go.mod index 8c3d465bfba..c2f6e3d99a1 100644 --- a/go.mod +++ b/go.mod @@ -198,6 +198,7 @@ require ( github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect github.com/docker/distribution v2.8.2+incompatible // indirect github.com/docker/go-connections v0.5.0 // indirect + github.com/doyensec/safeurl v0.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/dvsekhvalnov/jose2go v1.7.0 // indirect github.com/ethereum/c-kzg-4844 v1.0.0 // indirect diff --git a/go.sum b/go.sum index c185e1ad1d2..afd5da97847 100644 --- a/go.sum +++ b/go.sum @@ -358,6 +358,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo= github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc= +github.com/doyensec/safeurl v0.2.1 h1:DY15JorEfQsnpBWhBkVQIkaif2jfxCC14PIuGDsjDVs= +github.com/doyensec/safeurl v0.2.1/go.mod h1:wzSXqC/6Z410qHz23jtBWT+wQ8yTxcY0p8bZH/4EZIg= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=