new-api/service/channel_monitor_ssrf.go
zizi 0625c6304b fix: protect channel monitor dial targets
Validate resolved dial IPs in the channel monitor HTTP transport so DNS rebinding cannot bypass SSRF checks.
2026-05-20 15:41:06 +08:00

198 lines
4.9 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
)
var channelMonitorBlockedIPNets = mustParseChannelMonitorCIDRs([]string{
"0.0.0.0/8",
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.0.0.0/24",
"192.0.2.0/24",
"192.168.0.0/16",
"198.18.0.0/15",
"198.51.100.0/24",
"203.0.113.0/24",
"224.0.0.0/4",
"240.0.0.0/4",
"255.255.255.255/32",
"::/128",
"::1/128",
"64:ff9b::/96",
"100::/64",
"2001::/23",
"2001:db8::/32",
"fc00::/7",
"fe80::/10",
"ff00::/8",
})
func ValidateChannelMonitorTargetURL(target string) error {
target = strings.TrimSpace(target)
if target == "" {
return fmt.Errorf("empty URL")
}
parsedURL, err := url.Parse(target)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return fmt.Errorf("only http and https allowed")
}
host := parsedURL.Hostname()
if host == "" {
return fmt.Errorf("empty host")
}
if strings.ContainsAny(host, " \t\r\n") {
return fmt.Errorf("invalid host: contains whitespace")
}
if port := parsedURL.Port(); port != "" {
portNumber, err := strconv.Atoi(port)
if err != nil || portNumber < 1 || portNumber > 65535 {
return fmt.Errorf("invalid port: %s", port)
}
}
ips, err := resolveChannelMonitorTargetHost(host)
if err != nil {
return err
}
for _, ip := range ips {
if isBlockedChannelMonitorTargetIP(ip) {
return fmt.Errorf("private/internal IP not allowed: %s resolves to %s", host, ip.String())
}
}
return nil
}
func resolveChannelMonitorTargetHost(host string) ([]net.IP, error) {
if ip := net.ParseIP(host); ip != nil {
return []net.IP{ip}, nil
}
ips, err := net.LookupIP(host)
if err != nil {
return nil, fmt.Errorf("DNS lookup failed for %s: %w", host, err)
}
if len(ips) == 0 {
return nil, fmt.Errorf("no IP resolved for host: %s", host)
}
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
}
if ip.IsUnspecified() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() {
return true
}
checkIP := ip
if ipv4 := ip.To4(); ipv4 != nil {
checkIP = ipv4
}
for _, blockedNet := range channelMonitorBlockedIPNets {
if blockedNet.Contains(checkIP) || blockedNet.Contains(ip) {
return true
}
}
return false
}
func mustParseChannelMonitorCIDRs(cidrs []string) []net.IPNet {
nets := make([]net.IPNet, 0, len(cidrs))
for _, cidr := range cidrs {
_, parsedNet, err := net.ParseCIDR(cidr)
if err != nil {
panic(fmt.Sprintf("invalid channel monitor SSRF CIDR %s: %v", cidr, err))
}
nets = append(nets, *parsedNet)
}
return nets
}