feat(ssrf): implement SSRF protection in HTTP clients and validation functions
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type staticSSRFResolver map[string][]net.IPAddr
|
||||
|
||||
func (r staticSSRFResolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
|
||||
if ips, ok := r[host]; ok {
|
||||
return ips, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected lookup for %s", host)
|
||||
}
|
||||
|
||||
func staticProtection(protection *common.SSRFProtection) func() (*common.SSRFProtection, bool, error) {
|
||||
return func() (*common.SSRFProtection, bool, error) {
|
||||
return protection, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func testConn(t *testing.T) net.Conn {
|
||||
t.Helper()
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
clientConn.Close()
|
||||
serverConn.Close()
|
||||
})
|
||||
return clientConn
|
||||
}
|
||||
|
||||
func configureSSRFTestFetchSetting(t *testing.T) {
|
||||
t.Helper()
|
||||
fetchSetting := system_setting.GetFetchSetting()
|
||||
original := *fetchSetting
|
||||
t.Cleanup(func() {
|
||||
*fetchSetting = original
|
||||
})
|
||||
|
||||
fetchSetting.EnableSSRFProtection = true
|
||||
fetchSetting.AllowPrivateIp = false
|
||||
fetchSetting.DomainFilterMode = false
|
||||
fetchSetting.IpFilterMode = false
|
||||
fetchSetting.DomainList = nil
|
||||
fetchSetting.IpList = nil
|
||||
fetchSetting.AllowedPorts = []string{"80", "443"}
|
||||
fetchSetting.ApplyIPFilterForDomain = true
|
||||
}
|
||||
|
||||
func mustParseURL(t *testing.T, rawURL string) *url.URL {
|
||||
t.Helper()
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
require.NoError(t, err)
|
||||
return parsedURL
|
||||
}
|
||||
|
||||
func TestProtectedFetchDialerRejectsPrivateReboundAddress(t *testing.T) {
|
||||
dialer := &protectedFetchDialer{
|
||||
resolver: staticSSRFResolver{
|
||||
"safe.example": {{IP: net.ParseIP("127.0.0.1")}},
|
||||
},
|
||||
dialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
t.Fatalf("dialContext should not be called for blocked address %s", address)
|
||||
return nil, nil
|
||||
},
|
||||
getProtection: staticProtection(&common.SSRFProtection{
|
||||
AllowPrivateIp: false,
|
||||
DomainFilterMode: false,
|
||||
IpFilterMode: false,
|
||||
ApplyIPFilterForDomain: true,
|
||||
}),
|
||||
}
|
||||
|
||||
conn, err := dialer.DialContext(context.Background(), "tcp", "safe.example:80")
|
||||
|
||||
require.Error(t, err)
|
||||
require.Nil(t, conn)
|
||||
require.Contains(t, err.Error(), "private IP address not allowed")
|
||||
}
|
||||
|
||||
func TestProtectedFetchDialerRejectsMixedResolvedIPs(t *testing.T) {
|
||||
var dialed []string
|
||||
dialer := &protectedFetchDialer{
|
||||
resolver: staticSSRFResolver{
|
||||
"safe.example": {
|
||||
{IP: net.ParseIP("10.0.0.1")},
|
||||
{IP: net.ParseIP("8.8.8.8")},
|
||||
},
|
||||
},
|
||||
dialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
dialed = append(dialed, address)
|
||||
return testConn(t), nil
|
||||
},
|
||||
getProtection: staticProtection(&common.SSRFProtection{
|
||||
AllowPrivateIp: false,
|
||||
DomainFilterMode: false,
|
||||
IpFilterMode: false,
|
||||
ApplyIPFilterForDomain: true,
|
||||
}),
|
||||
}
|
||||
|
||||
conn, err := dialer.DialContext(context.Background(), "tcp", "safe.example:443")
|
||||
require.Error(t, err)
|
||||
require.Nil(t, conn)
|
||||
|
||||
require.Empty(t, dialed)
|
||||
require.Contains(t, err.Error(), "private IP address not allowed")
|
||||
}
|
||||
|
||||
func TestProtectedFetchDialerDialsWhenAllResolvedIPsAllowed(t *testing.T) {
|
||||
var dialed []string
|
||||
dialer := &protectedFetchDialer{
|
||||
resolver: staticSSRFResolver{
|
||||
"safe.example": {
|
||||
{IP: net.ParseIP("8.8.8.8")},
|
||||
{IP: net.ParseIP("1.1.1.1")},
|
||||
},
|
||||
},
|
||||
dialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
dialed = append(dialed, address)
|
||||
return testConn(t), nil
|
||||
},
|
||||
getProtection: staticProtection(&common.SSRFProtection{
|
||||
AllowPrivateIp: false,
|
||||
DomainFilterMode: false,
|
||||
IpFilterMode: false,
|
||||
ApplyIPFilterForDomain: true,
|
||||
}),
|
||||
}
|
||||
|
||||
conn, err := dialer.DialContext(context.Background(), "tcp", "safe.example:443")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conn)
|
||||
|
||||
require.Equal(t, []string{"8.8.8.8:443"}, dialed)
|
||||
}
|
||||
|
||||
func TestProtectedFetchDialerAllowsPrivateIPWhenWhitelisted(t *testing.T) {
|
||||
var dialed []string
|
||||
dialer := &protectedFetchDialer{
|
||||
resolver: staticSSRFResolver{
|
||||
"internal.example": {{IP: net.ParseIP("10.1.2.3")}},
|
||||
},
|
||||
dialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
dialed = append(dialed, address)
|
||||
return testConn(t), nil
|
||||
},
|
||||
getProtection: staticProtection(&common.SSRFProtection{
|
||||
AllowPrivateIp: true,
|
||||
DomainFilterMode: false,
|
||||
IpFilterMode: true,
|
||||
IpList: []string{"10.0.0.0/8"},
|
||||
ApplyIPFilterForDomain: true,
|
||||
}),
|
||||
}
|
||||
|
||||
conn, err := dialer.DialContext(context.Background(), "tcp", "internal.example:80")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conn)
|
||||
|
||||
require.Equal(t, []string{"10.1.2.3:80"}, dialed)
|
||||
}
|
||||
|
||||
func TestProtectedFetchDialerSkipsResolvedIPCheckWhenDisabled(t *testing.T) {
|
||||
var dialed []string
|
||||
dialer := &protectedFetchDialer{
|
||||
resolver: staticSSRFResolver{},
|
||||
dialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
dialed = append(dialed, address)
|
||||
return testConn(t), nil
|
||||
},
|
||||
getProtection: staticProtection(&common.SSRFProtection{
|
||||
AllowPrivateIp: false,
|
||||
DomainFilterMode: false,
|
||||
IpFilterMode: false,
|
||||
ApplyIPFilterForDomain: false,
|
||||
}),
|
||||
}
|
||||
|
||||
conn, err := dialer.DialContext(context.Background(), "tcp", "safe.example:80")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conn)
|
||||
|
||||
require.Equal(t, []string{"safe.example:80"}, dialed)
|
||||
}
|
||||
|
||||
func TestGetSSRFProtectedHTTPClientFallsBackToDefaultClientWhenProtectionDisabled(t *testing.T) {
|
||||
fetchSetting := system_setting.GetFetchSetting()
|
||||
originalFetchSetting := *fetchSetting
|
||||
originalHTTPClient := httpClient
|
||||
originalProtectedClient := ssrfProtectedHTTPClient
|
||||
t.Cleanup(func() {
|
||||
*fetchSetting = originalFetchSetting
|
||||
httpClient = originalHTTPClient
|
||||
ssrfProtectedHTTPClient = originalProtectedClient
|
||||
})
|
||||
|
||||
fetchSetting.EnableSSRFProtection = false
|
||||
expected := &http.Client{}
|
||||
httpClient = expected
|
||||
ssrfProtectedHTTPClient = &http.Client{}
|
||||
|
||||
require.Same(t, expected, GetSSRFProtectedHTTPClient())
|
||||
}
|
||||
|
||||
func TestProtectedFetchRoundTripperUsesConfiguredProxy(t *testing.T) {
|
||||
configureSSRFTestFetchSetting(t)
|
||||
proxyURL := mustParseURL(t, "http://127.0.0.1:3128")
|
||||
var dialed []string
|
||||
client := newProtectedFetchHTTPClientWithProxy(
|
||||
staticSSRFResolver{},
|
||||
func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
dialed = append(dialed, address)
|
||||
return nil, errors.New("stop after proxy dial")
|
||||
},
|
||||
staticProtection(&common.SSRFProtection{
|
||||
AllowPrivateIp: false,
|
||||
DomainFilterMode: false,
|
||||
IpFilterMode: false,
|
||||
ApplyIPFilterForDomain: true,
|
||||
}),
|
||||
func(req *http.Request) (*url.URL, error) {
|
||||
return proxyURL, nil
|
||||
},
|
||||
)
|
||||
req, err := http.NewRequest(http.MethodGet, "http://93.184.216.34/resource", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, resp)
|
||||
require.Equal(t, []string{"127.0.0.1:3128"}, dialed)
|
||||
}
|
||||
|
||||
func TestProtectedFetchRoundTripperRejectsPrivateTargetBeforeProxy(t *testing.T) {
|
||||
configureSSRFTestFetchSetting(t)
|
||||
proxyURL := mustParseURL(t, "http://127.0.0.1:3128")
|
||||
var dialed []string
|
||||
client := newProtectedFetchHTTPClientWithProxy(
|
||||
staticSSRFResolver{},
|
||||
func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
dialed = append(dialed, address)
|
||||
return nil, errors.New("proxy should not be dialed")
|
||||
},
|
||||
staticProtection(&common.SSRFProtection{
|
||||
AllowPrivateIp: false,
|
||||
DomainFilterMode: false,
|
||||
IpFilterMode: false,
|
||||
ApplyIPFilterForDomain: true,
|
||||
}),
|
||||
func(req *http.Request) (*url.URL, error) {
|
||||
return proxyURL, nil
|
||||
},
|
||||
)
|
||||
req, err := http.NewRequest(http.MethodGet, "http://localhost/resource", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, resp)
|
||||
require.Contains(t, err.Error(), "private IP address not allowed")
|
||||
require.Empty(t, dialed)
|
||||
}
|
||||
|
||||
func TestProtectedFetchRoundTripperNoProxyUsesProtectedDialer(t *testing.T) {
|
||||
configureSSRFTestFetchSetting(t)
|
||||
var dialed []string
|
||||
client := newProtectedFetchHTTPClientWithProxy(
|
||||
staticSSRFResolver{},
|
||||
func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
dialed = append(dialed, address)
|
||||
return nil, errors.New("unexpected direct dial")
|
||||
},
|
||||
staticProtection(&common.SSRFProtection{
|
||||
AllowPrivateIp: false,
|
||||
DomainFilterMode: false,
|
||||
IpFilterMode: false,
|
||||
ApplyIPFilterForDomain: true,
|
||||
}),
|
||||
func(req *http.Request) (*url.URL, error) {
|
||||
return nil, nil
|
||||
},
|
||||
)
|
||||
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/resource", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, resp)
|
||||
require.Contains(t, err.Error(), "private IP address not allowed")
|
||||
require.Empty(t, dialed)
|
||||
}
|
||||
|
||||
func TestProtectedFetchRoundTripperReusesTransportPerProxy(t *testing.T) {
|
||||
client := newProtectedFetchHTTPClientWithDialer(nil, nil, nil)
|
||||
roundTripper, ok := client.Transport.(*ssrfProtectedRoundTripper)
|
||||
require.True(t, ok)
|
||||
|
||||
direct := roundTripper.transportFor(nil)
|
||||
directAgain := roundTripper.transportFor(nil)
|
||||
proxied := roundTripper.transportFor(mustParseURL(t, "http://127.0.0.1:3128"))
|
||||
|
||||
require.Same(t, direct, directAgain)
|
||||
require.NotSame(t, direct, proxied)
|
||||
require.True(t, direct.ForceAttemptHTTP2)
|
||||
require.False(t, direct.DisableKeepAlives)
|
||||
}
|
||||
Reference in New Issue
Block a user