mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 18:11:00 +00:00
b51f09768b
IsBlockedIP leaned entirely on Go's net.IP predicates, which judge an address by its own range only. 6to4 (2002::/16), NAT64 (64:ff9b::/96 and 64:ff9b:1::/48) and Teredo (2001::/32) each tunnel an arbitrary IPv4 destination inside an IPv6 address, so all five predicates returned false for e.g. 64:ff9b::7f00:1 and the SSRF guard waved it through. CGNAT (100.64.0.0/10) and the deprecated site-local block were unclassified for the same reason. Reported as GHSA-cfpf-wmjp-gh6c. Reaching the embedded IPv4 needs a 6to4 tunnel, NAT64 gateway or Teredo client on the host, none of which exist by default, so this is hardening rather than a live path off a stock install. The guard backs outbound subscription fetches, node sync, reality scan, the tgbot API URL and the xray setting test URL, which is reason enough to close the gap. The deprecated and local-use prefixes are blocked outright since nothing public routes through them. The NAT64 well-known prefix is judged by the IPv4 it embeds instead: on a DNS64 network every public IPv4 host resolves into it, so blocking it wholesale would break legitimate fetches.
127 lines
3.6 KiB
Go
127 lines
3.6 KiB
Go
package netsafe
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ErrPrivateAddressBlocked marks a failed dial where the guard refused at least
|
|
// one resolved address, so a caller offering an opt-in can tell it apart from an
|
|
// ordinary connection failure.
|
|
var ErrPrivateAddressBlocked = errors.New("blocked private/internal address")
|
|
|
|
// Ranges Go's net.IP predicates do not treat as internal. The transition
|
|
// mechanisms here are deprecated (RFC 7526) or local-use, so none carry public traffic.
|
|
var blockedPrefixes = []netip.Prefix{
|
|
netip.MustParsePrefix("100.64.0.0/10"), // CGNAT (RFC 6598)
|
|
netip.MustParsePrefix("2002::/16"), // 6to4 (RFC 3056)
|
|
netip.MustParsePrefix("2001::/32"), // Teredo (RFC 4380)
|
|
netip.MustParsePrefix("64:ff9b:1::/48"), // NAT64 local-use (RFC 8215)
|
|
netip.MustParsePrefix("fec0::/10"), // site-local (RFC 3879)
|
|
}
|
|
|
|
// Judged by the IPv4 it embeds rather than blocked outright: on a DNS64 network
|
|
// every public IPv4 host resolves into this prefix (RFC 6052 mandates /96 here).
|
|
var nat64WellKnown = netip.MustParsePrefix("64:ff9b::/96")
|
|
|
|
func IsBlockedIP(ip net.IP) bool {
|
|
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
|
ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
|
|
return true
|
|
}
|
|
addr, ok := netip.AddrFromSlice(ip)
|
|
if !ok {
|
|
return false
|
|
}
|
|
addr = addr.Unmap()
|
|
for _, prefix := range blockedPrefixes {
|
|
if prefix.Contains(addr) {
|
|
return true
|
|
}
|
|
}
|
|
if nat64WellKnown.Contains(addr) {
|
|
embedded := addr.As16()
|
|
return IsBlockedIP(net.IP(embedded[12:16]))
|
|
}
|
|
return false
|
|
}
|
|
|
|
type allowPrivateCtxKey struct{}
|
|
|
|
func ContextWithAllowPrivate(ctx context.Context, allow bool) context.Context {
|
|
return context.WithValue(ctx, allowPrivateCtxKey{}, allow)
|
|
}
|
|
|
|
func AllowPrivateFromContext(ctx context.Context) bool {
|
|
v, _ := ctx.Value(allowPrivateCtxKey{}).(bool)
|
|
return v
|
|
}
|
|
|
|
var defaultDialer = &net.Dialer{Timeout: 10 * time.Second}
|
|
|
|
func SSRFGuardedDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
allowPrivate := AllowPrivateFromContext(ctx)
|
|
var ips []net.IPAddr
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
ips = []net.IPAddr{{IP: ip}}
|
|
} else {
|
|
ips, err = net.DefaultResolver.LookupIPAddr(ctx, host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
var lastErr, blockedErr error
|
|
for _, ipAddr := range ips {
|
|
if !allowPrivate && IsBlockedIP(ipAddr.IP) {
|
|
blockedErr = fmt.Errorf("%w %s", ErrPrivateAddressBlocked, ipAddr.IP)
|
|
continue
|
|
}
|
|
conn, derr := defaultDialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port))
|
|
if derr == nil {
|
|
return conn, nil
|
|
}
|
|
lastErr = derr
|
|
}
|
|
// A dual-stack name can mix refused and merely unreachable addresses, so the
|
|
// refusal is reported alongside instead of being lost to the last failure.
|
|
if blockedErr != nil {
|
|
if lastErr != nil {
|
|
return nil, fmt.Errorf("%w; %w", blockedErr, lastErr)
|
|
}
|
|
return nil, blockedErr
|
|
}
|
|
if lastErr == nil {
|
|
lastErr = fmt.Errorf("no usable address for %s", host)
|
|
}
|
|
return nil, lastErr
|
|
}
|
|
|
|
var hostnamePattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$`)
|
|
|
|
func NormalizeHost(addr string) (string, error) {
|
|
addr = strings.TrimSpace(addr)
|
|
if addr == "" {
|
|
return "", fmt.Errorf("address is required")
|
|
}
|
|
if strings.HasPrefix(addr, "[") && strings.HasSuffix(addr, "]") {
|
|
addr = addr[1 : len(addr)-1]
|
|
}
|
|
if ip := net.ParseIP(addr); ip != nil {
|
|
return ip.String(), nil
|
|
}
|
|
if len(addr) > 253 || !hostnamePattern.MatchString(addr) {
|
|
return "", fmt.Errorf("invalid host %q", addr)
|
|
}
|
|
return addr, nil
|
|
}
|