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:
parent
24235f2872
commit
0625c6304b
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user