diff --git a/service/channel_monitor_checker.go b/service/channel_monitor_checker.go index baad47fb..7bf32194 100644 --- a/service/channel_monitor_checker.go +++ b/service/channel_monitor_checker.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "strings" "time" @@ -196,8 +197,19 @@ func parseChannelMonitorCustomHeaders(raw string) (map[string]string, error) { } func newChannelMonitorHTTPClient(timeout time.Duration, validateURL func(string) error) *http.Client { + dialer := &net.Dialer{ + Timeout: timeout, + KeepAlive: 30 * time.Second, + } return &http.Client{ Timeout: timeout, + Transport: &http.Transport{ + DialContext: newChannelMonitorProtectedDialContext(dialer), + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + }, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return errors.New("stopped after 10 redirects") diff --git a/service/channel_monitor_ssrf.go b/service/channel_monitor_ssrf.go index 066386ae..b2ddc9dd 100644 --- a/service/channel_monitor_ssrf.go +++ b/service/channel_monitor_ssrf.go @@ -1,11 +1,14 @@ package service import ( + "context" + "errors" "fmt" "net" "net/url" "strconv" "strings" + "time" ) var channelMonitorBlockedIPNets = mustParseChannelMonitorCIDRs([]string{ @@ -90,6 +93,77 @@ func resolveChannelMonitorTargetHost(host string) ([]net.IP, error) { return ips, nil } +func newChannelMonitorProtectedDialContext(dialer *net.Dialer) func(context.Context, string, string) (net.Conn, error) { + if dialer == nil { + dialer = &net.Dialer{Timeout: 30 * time.Second} + } + return channelMonitorProtectedDialContext(dialer.DialContext, net.DefaultResolver.LookupIPAddr) +} + +func channelMonitorProtectedDialContext( + dial func(context.Context, string, string) (net.Conn, error), + lookupIPAddr func(context.Context, string) ([]net.IPAddr, error), +) func(context.Context, string, string) (net.Conn, error) { + return func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("invalid dial address: %w", err) + } + if host == "" { + return nil, fmt.Errorf("empty dial host") + } + + ips, err := resolveChannelMonitorDialIPs(ctx, host, lookupIPAddr) + if err != nil { + return nil, err + } + + var dialErrs []error + for _, ip := range ips { + if isBlockedChannelMonitorTargetIP(ip) { + return nil, fmt.Errorf("private/internal IP not allowed: %s resolved to %s", host, ip.String()) + } + conn, err := dial(ctx, network, net.JoinHostPort(ip.String(), port)) + if err == nil { + return conn, nil + } + dialErrs = append(dialErrs, err) + } + if len(dialErrs) > 0 { + return nil, fmt.Errorf("channel monitor dial rejected or failed: %w", errors.Join(dialErrs...)) + } + return nil, fmt.Errorf("no IP resolved for host: %s", host) + } +} + +func resolveChannelMonitorDialIPs(ctx context.Context, host string, lookupIPAddr func(context.Context, string) ([]net.IPAddr, error)) ([]net.IP, error) { + if ip := net.ParseIP(host); ip != nil { + return []net.IP{ip}, nil + } + if lookupIPAddr == nil { + lookupIPAddr = net.DefaultResolver.LookupIPAddr + } + + addresses, err := lookupIPAddr(ctx, host) + if err != nil { + return nil, fmt.Errorf("DNS lookup failed for %s: %w", host, err) + } + if len(addresses) == 0 { + return nil, fmt.Errorf("no IP resolved for host: %s", host) + } + + ips := make([]net.IP, 0, len(addresses)) + for _, address := range addresses { + if address.IP != nil { + ips = append(ips, address.IP) + } + } + if len(ips) == 0 { + return nil, fmt.Errorf("no IP resolved for host: %s", host) + } + return ips, nil +} + func isBlockedChannelMonitorTargetIP(ip net.IP) bool { if ip == nil { return true diff --git a/service/channel_monitor_ssrf_test.go b/service/channel_monitor_ssrf_test.go index cf68117c..cbdf3914 100644 --- a/service/channel_monitor_ssrf_test.go +++ b/service/channel_monitor_ssrf_test.go @@ -1,6 +1,10 @@ package service import ( + "context" + "errors" + "net" + "strings" "testing" "github.com/stretchr/testify/require" @@ -85,3 +89,47 @@ func TestValidateChannelMonitorTargetURLAllowsPublicHTTPSTarget(t *testing.T) { require.NoError(t, err) } + +func TestChannelMonitorProtectedDialContextRejectsResolvedInternalIP(t *testing.T) { + t.Parallel() + + dialCalled := false + dialContext := channelMonitorProtectedDialContext( + func(context.Context, string, string) (net.Conn, error) { + dialCalled = true + return nil, errors.New("dial should not be called") + }, + func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil + }, + ) + + conn, err := dialContext(context.Background(), "tcp", "rebind.example:443") + + require.Nil(t, conn) + require.Error(t, err) + require.Contains(t, err.Error(), "private/internal IP not allowed") + require.False(t, dialCalled) +} + +func TestChannelMonitorProtectedDialContextDialsResolvedPublicIP(t *testing.T) { + t.Parallel() + + var dialedAddress string + dialContext := channelMonitorProtectedDialContext( + func(_ context.Context, _ string, addr string) (net.Conn, error) { + dialedAddress = strings.TrimSpace(addr) + return nil, errors.New("stop after address capture") + }, + func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil + }, + ) + + conn, err := dialContext(context.Background(), "tcp", "example.com:443") + + require.Nil(t, conn) + require.Error(t, err) + require.Contains(t, err.Error(), "stop after address capture") + require.Equal(t, "93.184.216.34:443", dialedAddress) +}