// Package netguard blocks server-initiated connections to non-public // destinations. It backs URL previews, CardDAV sync and IMAP connections so a // user-supplied host can never point the server at another user's internal // service, the Docker database, or cloud metadata endpoints. package netguard import ( "context" "errors" "net" "os" "strconv" "time" ) // AllowPrivate reports whether range checks are disabled via // INTEGRATIONS_ALLOW_PRIVATE=1. Tests set it for loopback fake servers; // operators can set it for deliberate LAN deployments (home Nextcloud or // mail on private addresses). It is off in the production Compose stack. func AllowPrivate() bool { return os.Getenv("INTEGRATIONS_ALLOW_PRIVATE") == "1" } // extraBlocked covers ranges net.IP's helpers miss. IPv4: "this network" // (0.0.0.0/8), RFC 6598 CGNAT (100.64.0.0/10 — used by Tailscale, GKE // internal LBs, some Docker setups), IETF protocol assignments // (192.0.0.0/24), TEST-NET-1/2/3 documentation space (192.0.2.0/24, // 198.51.100.0/24, 203.0.113.0/24), deprecated 6to4 relay anycast // (192.88.99.0/24), benchmark space (198.18.0.0/15), and reserved/limited // broadcast space (240.0.0.0/4, which includes 255.255.255.255). // IPv6: discard-only (100::/64), benchmarking (2001:2::/48), ORCHID/ORCHIDv2 // (2001:10::/28, 2001:20::/28), documentation (2001:db8::/32, 3fff::/20), // SRv6 SIDs (5f00::/16), deprecated 6bone (3ffe::/16), and site-local // fec0::/10. The transition prefixes with embedded IPv4 (6to4, Teredo, // IPv4-compatible/translated, NAT64) are handled explicitly below. var extraBlocked = func() []net.IPNet { cidrs := []string{ "0.0.0.0/8", "100.64.0.0/10", "192.0.0.0/24", "192.0.2.0/24", "192.88.99.0/24", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "240.0.0.0/4", "100::/64", "2001:2::/48", "2001:10::/28", "2001:20::/28", "2001:db8::/32", "3ffe::/16", "3fff::/20", "5f00::/16", "fec0::/10", } nets := make([]net.IPNet, 0, len(cidrs)) for _, cidr := range cidrs { _, n, err := net.ParseCIDR(cidr) if err != nil { panic("netguard: bad CIDR " + cidr) } nets = append(nets, *n) } return nets }() // parsePrefix is the panic-on-programmer-error helper for the fixed policy // prefixes below. func parsePrefix(cidr string) net.IPNet { _, n, err := net.ParseCIDR(cidr) if err != nil { panic("netguard: bad CIDR " + cidr) } return *n } // Transition prefixes that embed an IPv4 destination in a different place // than the well-known NAT64 layout. They are the "rare IPv6 tricks" from the // review: without these checks an IPv6 literal can smuggle a loopback or // RFC 1918 destination past the range guard. var ( // RFC 6052 well-known NAT64: low 32 bits are the IPv4 destination. nat64Prefix = parsePrefix("64:ff9b::/96") // RFC 8215 local-use NAT64: never a public destination. nat64LocalPrefix = parsePrefix("64:ff9b:1::/48") // RFC 3056 6to4: bits 16..47 are the embedded IPv4 destination. sixToFourPrefix = parsePrefix("2002::/16") // RFC 4380 Teredo: block the whole prefix. Its client address is // obfuscated and the route depends on a relay, so judging an embedded // value is both unreliable and unnecessary for public web/mail targets. teredoPrefix = parsePrefix("2001::/32") // Deprecated IPv4-compatible (::/96) and IPv4-translated // (::ffff:0:0:0/96) addresses. These are not IPv4-mapped addresses (those // have To4() != nil and are judged directly above). ipv4CompatiblePrefix = parsePrefix("::/96") ipv4TranslatedPrefix = parsePrefix("::ffff:0:0:0/96") ) func publicIPv4(ip net.IP) bool { v4 := ip.To4() if v4 == nil || !v4.IsGlobalUnicast() || v4.IsPrivate() || v4.IsLoopback() || v4.IsLinkLocalUnicast() { return false } for _, n := range extraBlocked { if n.Contains(v4) { return false } } return true } // Destination policy (review follow-up, stated explicitly): allowed targets // are globally routable unicast addresses only. Blocked: private (RFC 1918, // ULA fc00::/7), loopback, link-local, CGNAT, benchmark, IETF protocol and // documentation ranges, unspecified/broadcast/multicast, IPv4-mapped // addresses whose embedded IPv4 is non-public, and transition addresses // (6to4/NAT64) whose embedded IPv4 is non-public. Teredo, // IPv4-compatible/translated and local-use NAT64 prefixes are blocked // wholesale. INTEGRATIONS_ALLOW_PRIVATE=1 (fixtures and deliberate LAN // deployments) is the only relaxation. func IsPublic(ip net.IP) bool { if ip == nil || !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsMulticast() || ip.IsUnspecified() { return false } if v4 := ip.To4(); v4 != nil { return publicIPv4(v4) } v6 := ip.To16() if v6 == nil { return false } if ipv4CompatiblePrefix.Contains(v6) || ipv4TranslatedPrefix.Contains(v6) { return false } if sixToFourPrefix.Contains(v6) { return publicIPv4(net.IPv4(v6[2], v6[3], v6[4], v6[5])) } if teredoPrefix.Contains(v6) || nat64LocalPrefix.Contains(v6) { return false } if nat64Prefix.Contains(v6) { return publicIPv4(net.IPv4(v6[12], v6[13], v6[14], v6[15])) } for _, n := range extraBlocked { if n.Contains(v6) { return false } } return true } var errBlocked = errors.New("netguard: private destination blocked") var errBlockedPort = errors.New("netguard: destination port blocked") // DialContextPorts is DialContext with an allowlist of destination ports. // Callers that fetch user-supplied URLs use it to keep outbound traffic on // the protocol's normal ports: a preview has no business dialing SSH or a // database port on a public host. Fixtures and deliberate LAN deployments // can bypass it by calling DialContext directly when AllowPrivate is set. func DialContextPorts(ctx context.Context, network, address string, allowed ...int) (net.Conn, error) { _, rawPort, e := net.SplitHostPort(address) if e != nil { return nil, e } port, e := strconv.Atoi(rawPort) if e != nil { return nil, errBlockedPort } ok := false for _, p := range allowed { if port == p { ok = true break } } if !ok { return nil, errBlockedPort } return DialContext(ctx, network, address) } // dialMaxAttempts bounds the fallback across already-validated addresses // (review follow-up): the first A/AAAA record being unreachable does not make // the host unreachable, but we never chase an unbounded list — and there is // deliberately no second DNS lookup anywhere in this path. const dialMaxAttempts = 3 // DialContext resolves the host, rejects non-public destinations, then dials // the checked IPs. Check and dial use the same lookup result, so there is no // DNS-rebinding window between them. Every resolved address must pass the // range check (a host that mixes public and private records is refused // outright), and the dial then falls back among the validated addresses // instead of failing on the first unreachable one. func DialContext(ctx context.Context, network, address string) (net.Conn, error) { host, port, e := net.SplitHostPort(address) if e != nil { return nil, e } ips, e := net.DefaultResolver.LookupIPAddr(ctx, host) if e != nil { return nil, e } if len(ips) == 0 { return nil, errors.New("netguard: unresolved address") } if !AllowPrivate() { for _, a := range ips { if !IsPublic(a.IP) { return nil, errBlocked } } } dialer := &net.Dialer{Timeout: 10 * time.Second} var lastErr error for i, a := range ips { if i >= dialMaxAttempts { break } var conn net.Conn conn, lastErr = dialer.DialContext(ctx, network, net.JoinHostPort(a.IP.String(), port)) if lastErr == nil { return conn, nil } } return nil, lastErr }