Skip to content

Commit 504ed31

Browse files
[CRE-47] Add safeurl to protect against SSRF
1 parent 0fb7546 commit 504ed31

File tree

12 files changed

+182
-19
lines changed

12 files changed

+182
-19
lines changed

core/scripts/go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ require (
127127
github.com/docker/distribution v2.8.2+incompatible // indirect
128128
github.com/docker/go-units v0.5.0 // indirect
129129
github.com/dominikbraun/graph v0.23.0 // indirect
130+
github.com/doyensec/safeurl v0.2.1 // indirect
130131
github.com/dustin/go-humanize v1.0.1 // indirect
131132
github.com/dvsekhvalnov/jose2go v1.7.0 // indirect
132133
github.com/emicklei/go-restful/v3 v3.12.1 // indirect

core/scripts/go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
357357
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
358358
github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
359359
github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
360+
github.com/doyensec/safeurl v0.2.1 h1:DY15JorEfQsnpBWhBkVQIkaif2jfxCC14PIuGDsjDVs=
361+
github.com/doyensec/safeurl v0.2.1/go.mod h1:wzSXqC/6Z410qHz23jtBWT+wQ8yTxcY0p8bZH/4EZIg=
360362
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
361363
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
362364
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=

core/services/gateway/network/httpclient.go

+37-6
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"strings"
99
"time"
1010

11+
"github.com/doyensec/safeurl"
12+
1113
"github.com/smartcontractkit/chainlink-common/pkg/logger"
1214
)
1315

@@ -19,6 +21,28 @@ type HTTPClient interface {
1921
type HTTPClientConfig struct {
2022
MaxResponseBytes uint32
2123
DefaultTimeout time.Duration
24+
BlockedIPs []string
25+
BlockedIPsCIDR []string
26+
AllowedPorts []int
27+
AllowedSchemes []string
28+
}
29+
30+
var (
31+
defaultAllowedPorts = []int{80, 443}
32+
defaultAllowedSchemes = []string{"http", "https"}
33+
)
34+
35+
func (c *HTTPClientConfig) ApplyDefaults() {
36+
if len(c.AllowedPorts) == 0 {
37+
c.AllowedPorts = defaultAllowedPorts
38+
}
39+
40+
if len(c.AllowedSchemes) == 0 {
41+
c.AllowedSchemes = defaultAllowedSchemes
42+
}
43+
44+
// safeurl automatically blocks internal IPs so no need
45+
// to set defaults here.
2246
}
2347

2448
type HTTPRequest struct {
@@ -35,21 +59,28 @@ type HTTPResponse struct {
3559
}
3660

3761
type httpClient struct {
38-
client *http.Client
62+
client *safeurl.WrappedClient
3963
config HTTPClientConfig
4064
lggr logger.Logger
4165
}
4266

4367
// NewHTTPClient creates a new NewHTTPClient
4468
// As of now, the client does not support TLS configuration but may be extended in the future
4569
func NewHTTPClient(config HTTPClientConfig, lggr logger.Logger) (HTTPClient, error) {
70+
config.ApplyDefaults()
71+
safeConfig := safeurl.
72+
GetConfigBuilder().
73+
SetTimeout(config.DefaultTimeout).
74+
SetAllowedPorts(config.AllowedPorts...).
75+
SetAllowedSchemes(config.AllowedSchemes...).
76+
SetBlockedIPs(config.BlockedIPs...).
77+
SetBlockedIPsCIDR(config.BlockedIPsCIDR...).
78+
Build()
79+
4680
return &httpClient{
4781
config: config,
48-
client: &http.Client{
49-
Timeout: config.DefaultTimeout,
50-
Transport: http.DefaultTransport,
51-
},
52-
lggr: lggr,
82+
client: safeurl.Client(safeConfig),
83+
lggr: lggr,
5384
}, nil
5485
}
5586

core/services/gateway/network/httpclient_test.go

+130-13
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,37 @@
1-
package network_test
1+
package network
22

33
import (
44
"context"
55
"net/http"
66
"net/http/httptest"
7+
"net/url"
8+
"strconv"
79
"testing"
810
"time"
911

12+
"github.com/doyensec/safeurl"
1013
"github.com/stretchr/testify/require"
1114

1215
"github.com/smartcontractkit/chainlink-common/pkg/logger"
13-
"github.com/smartcontractkit/chainlink/v2/core/services/gateway/network"
1416
)
1517

1618
func TestHTTPClient_Send(t *testing.T) {
1719
t.Parallel()
1820

1921
// Setup the test environment
2022
lggr := logger.Test(t)
21-
config := network.HTTPClientConfig{
23+
config := HTTPClientConfig{
2224
MaxResponseBytes: 1024,
2325
DefaultTimeout: 5 * time.Second,
2426
}
25-
client, err := network.NewHTTPClient(config, lggr)
26-
require.NoError(t, err)
2727

2828
// Define test cases
2929
tests := []struct {
3030
name string
3131
setupServer func() *httptest.Server
32-
request network.HTTPRequest
32+
request HTTPRequest
3333
expectedError error
34-
expectedResp *network.HTTPResponse
34+
expectedResp *HTTPResponse
3535
}{
3636
{
3737
name: "successful request",
@@ -42,15 +42,15 @@ func TestHTTPClient_Send(t *testing.T) {
4242
require.NoError(t, err2)
4343
}))
4444
},
45-
request: network.HTTPRequest{
45+
request: HTTPRequest{
4646
Method: "GET",
4747
URL: "/",
4848
Headers: map[string]string{},
4949
Body: nil,
5050
Timeout: 2 * time.Second,
5151
},
5252
expectedError: nil,
53-
expectedResp: &network.HTTPResponse{
53+
expectedResp: &HTTPResponse{
5454
StatusCode: http.StatusOK,
5555
Headers: map[string]string{"Content-Length": "7"},
5656
Body: []byte("success"),
@@ -66,7 +66,7 @@ func TestHTTPClient_Send(t *testing.T) {
6666
require.NoError(t, err2)
6767
}))
6868
},
69-
request: network.HTTPRequest{
69+
request: HTTPRequest{
7070
Method: "GET",
7171
URL: "/",
7272
Headers: map[string]string{},
@@ -85,15 +85,15 @@ func TestHTTPClient_Send(t *testing.T) {
8585
require.NoError(t, err2)
8686
}))
8787
},
88-
request: network.HTTPRequest{
88+
request: HTTPRequest{
8989
Method: "GET",
9090
URL: "/",
9191
Headers: map[string]string{},
9292
Body: nil,
9393
Timeout: 2 * time.Second,
9494
},
9595
expectedError: nil,
96-
expectedResp: &network.HTTPResponse{
96+
expectedResp: &HTTPResponse{
9797
StatusCode: http.StatusInternalServerError,
9898
Headers: map[string]string{"Content-Length": "5"},
9999
Body: []byte("error"),
@@ -108,7 +108,7 @@ func TestHTTPClient_Send(t *testing.T) {
108108
require.NoError(t, err2)
109109
}))
110110
},
111-
request: network.HTTPRequest{
111+
request: HTTPRequest{
112112
Method: "GET",
113113
URL: "/",
114114
Headers: map[string]string{},
@@ -126,6 +126,26 @@ func TestHTTPClient_Send(t *testing.T) {
126126
server := tt.setupServer()
127127
defer server.Close()
128128

129+
u, err := url.Parse(server.URL)
130+
require.NoError(t, err)
131+
132+
hostname, port := u.Hostname(), u.Port()
133+
portInt, err := strconv.ParseInt(port, 10, 32)
134+
require.NoError(t, err)
135+
136+
safeConfig := safeurl.
137+
GetConfigBuilder().
138+
SetTimeout(config.DefaultTimeout).
139+
SetAllowedIPs(hostname).
140+
SetAllowedPorts(int(portInt)).
141+
Build()
142+
143+
client := &httpClient{
144+
config: config,
145+
client: safeurl.Client(safeConfig),
146+
lggr: lggr,
147+
}
148+
129149
tt.request.URL = server.URL + tt.request.URL
130150

131151
resp, err := client.Send(context.Background(), tt.request)
@@ -145,3 +165,100 @@ func TestHTTPClient_Send(t *testing.T) {
145165
})
146166
}
147167
}
168+
169+
func TestHTTPClient_BlocksUnallowed(t *testing.T) {
170+
t.Parallel()
171+
172+
// Setup the test environment
173+
lggr := logger.Test(t)
174+
config := HTTPClientConfig{
175+
MaxResponseBytes: 1024,
176+
DefaultTimeout: 5 * time.Second,
177+
}
178+
179+
client, err := NewHTTPClient(config, lggr)
180+
require.NoError(t, err)
181+
182+
// Define test cases
183+
tests := []struct {
184+
name string
185+
request HTTPRequest
186+
expectedError string
187+
}{
188+
{
189+
name: "blocked port",
190+
request: HTTPRequest{
191+
Method: "GET",
192+
URL: "http://127.0.0.1:8080",
193+
Headers: map[string]string{},
194+
Body: nil,
195+
Timeout: 2 * time.Second,
196+
},
197+
expectedError: "port: 8080 not found in allowlist",
198+
},
199+
{
200+
name: "blocked scheme",
201+
request: HTTPRequest{
202+
Method: "GET",
203+
URL: "file://127.0.0.1",
204+
Headers: map[string]string{},
205+
Body: nil,
206+
Timeout: 2 * time.Second,
207+
},
208+
expectedError: "scheme: file not found in allowlist",
209+
},
210+
{
211+
name: "explicitly blocked IP",
212+
request: HTTPRequest{
213+
Method: "GET",
214+
URL: "http://169.254.0.1",
215+
Headers: map[string]string{},
216+
Body: nil,
217+
Timeout: 2 * time.Second,
218+
},
219+
expectedError: "ip: 169.254.0.1 not found in allowlist",
220+
},
221+
{
222+
name: "explicitly blocked IP - internal network",
223+
request: HTTPRequest{
224+
Method: "GET",
225+
URL: "http://169.254.0.1/endpoint",
226+
Headers: map[string]string{},
227+
Body: nil,
228+
Timeout: 2 * time.Second,
229+
},
230+
expectedError: "ip: 169.254.0.1 not found in allowlist",
231+
},
232+
{
233+
name: "explicitly blocked IP - localhost",
234+
request: HTTPRequest{
235+
Method: "GET",
236+
URL: "http://127.0.0.1/endpoint",
237+
Headers: map[string]string{},
238+
Body: nil,
239+
Timeout: 2 * time.Second,
240+
},
241+
expectedError: "ip: 127.0.0.1 not found in allowlist",
242+
},
243+
{
244+
name: "explicitly blocked IP - current network",
245+
request: HTTPRequest{
246+
Method: "GET",
247+
URL: "http://0.0.0.0/endpoint",
248+
Headers: map[string]string{},
249+
Body: nil,
250+
Timeout: 2 * time.Second,
251+
},
252+
expectedError: "ip: 0.0.0.0 not found in allowlist",
253+
},
254+
}
255+
256+
// Execute test cases
257+
for _, tt := range tests {
258+
t.Run(tt.name, func(t *testing.T) {
259+
_, err := client.Send(context.Background(), tt.request)
260+
require.Error(t, err)
261+
require.ErrorContains(t, err, tt.expectedError)
262+
})
263+
}
264+
}

deployment/go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ require (
173173
github.com/docker/go-connections v0.5.0 // indirect
174174
github.com/docker/go-units v0.5.0 // indirect
175175
github.com/dominikbraun/graph v0.23.0 // indirect
176+
github.com/doyensec/safeurl v0.2.1 // indirect
176177
github.com/dustin/go-humanize v1.0.1 // indirect
177178
github.com/dvsekhvalnov/jose2go v1.7.0 // indirect
178179
github.com/edsrzf/mmap-go v1.1.0 // indirect

deployment/go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
481481
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
482482
github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
483483
github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
484+
github.com/doyensec/safeurl v0.2.1 h1:DY15JorEfQsnpBWhBkVQIkaif2jfxCC14PIuGDsjDVs=
485+
github.com/doyensec/safeurl v0.2.1/go.mod h1:wzSXqC/6Z410qHz23jtBWT+wQ8yTxcY0p8bZH/4EZIg=
484486
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
485487
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
486488
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=

go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ require (
1919
github.com/danielkov/gin-helmet v0.0.0-20171108135313-1387e224435e
2020
github.com/deckarep/golang-set/v2 v2.6.0
2121
github.com/dominikbraun/graph v0.23.0
22+
github.com/doyensec/safeurl v0.2.1
2223
github.com/esote/minmaxheap v1.0.0
2324
github.com/ethereum/go-ethereum v1.14.11
2425
github.com/fatih/color v1.17.0

go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
358358
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
359359
github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
360360
github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
361+
github.com/doyensec/safeurl v0.2.1 h1:DY15JorEfQsnpBWhBkVQIkaif2jfxCC14PIuGDsjDVs=
362+
github.com/doyensec/safeurl v0.2.1/go.mod h1:wzSXqC/6Z410qHz23jtBWT+wQ8yTxcY0p8bZH/4EZIg=
361363
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
362364
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
363365
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=

integration-tests/go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ require (
196196
github.com/docker/go-connections v0.5.0 // indirect
197197
github.com/docker/go-units v0.5.0 // indirect
198198
github.com/dominikbraun/graph v0.23.0 // indirect
199+
github.com/doyensec/safeurl v0.2.1 // indirect
199200
github.com/dustin/go-humanize v1.0.1 // indirect
200201
github.com/dvsekhvalnov/jose2go v1.7.0 // indirect
201202
github.com/edsrzf/mmap-go v1.1.0 // indirect

integration-tests/go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
483483
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
484484
github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
485485
github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
486+
github.com/doyensec/safeurl v0.2.1 h1:DY15JorEfQsnpBWhBkVQIkaif2jfxCC14PIuGDsjDVs=
487+
github.com/doyensec/safeurl v0.2.1/go.mod h1:wzSXqC/6Z410qHz23jtBWT+wQ8yTxcY0p8bZH/4EZIg=
486488
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
487489
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
488490
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=

integration-tests/load/go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ require (
166166
github.com/docker/go-connections v0.5.0 // indirect
167167
github.com/docker/go-units v0.5.0 // indirect
168168
github.com/dominikbraun/graph v0.23.0 // indirect
169+
github.com/doyensec/safeurl v0.2.1 // indirect
169170
github.com/dustin/go-humanize v1.0.1 // indirect
170171
github.com/dvsekhvalnov/jose2go v1.7.0 // indirect
171172
github.com/edsrzf/mmap-go v1.1.0 // indirect

integration-tests/load/go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
477477
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
478478
github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
479479
github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
480+
github.com/doyensec/safeurl v0.2.1 h1:DY15JorEfQsnpBWhBkVQIkaif2jfxCC14PIuGDsjDVs=
481+
github.com/doyensec/safeurl v0.2.1/go.mod h1:wzSXqC/6Z410qHz23jtBWT+wQ8yTxcY0p8bZH/4EZIg=
480482
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
481483
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
482484
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=

0 commit comments

Comments
 (0)