fix: protect channel monitor dial targets

Validate resolved dial IPs in the channel monitor HTTP transport so DNS rebinding cannot bypass SSRF checks.
This commit is contained in:
zizi 2026-05-20 15:41:06 +08:00
parent 24235f2872
commit 0625c6304b
3 changed files with 134 additions and 0 deletions

View File

@ -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")

View File

@ -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

View File

@ -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)
}