From 09da089a90993e781a885878aecd397ff616f54f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 9 Mar 2026 16:30:11 +0100 Subject: [PATCH] [client] filter CGNAT and CNI addresses from ICE candidates In Kubernetes environments using Cilium or similar CNI plugins, pod CIDR addresses (e.g. 100.65.x.x) from the RFC 6598 CGNAT range (100.64.0.0/10) were being gathered as valid ICE host candidates. This caused WireGuard endpoints to resolve to non-routable pod IPs, producing overlay-routed connections with degraded latency instead of true P2P paths between hosts. Add three layers of defense: - Expand the default interface blacklist with common Kubernetes CNI interface prefixes (cilium_, lxc, cali, flannel, cni, weave) - Filter local and remote ICE candidates whose addresses fall within the CGNAT range but outside the NetBird WireGuard network - Reject UDP mux writes to CGNAT addresses as a defense-in-depth fallback --- client/iface/udpmux/universal.go | 14 +++++++++ client/internal/peer/worker_ice.go | 39 ++++++++++++++++++++++++ client/internal/profilemanager/config.go | 2 ++ 3 files changed, 55 insertions(+) diff --git a/client/iface/udpmux/universal.go b/client/iface/udpmux/universal.go index 43bfedaaa..8ba26de01 100644 --- a/client/iface/udpmux/universal.go +++ b/client/iface/udpmux/universal.go @@ -22,6 +22,11 @@ import ( "github.com/netbirdio/netbird/client/iface/wgaddr" ) +// cgnatPrefix is the RFC 6598 Carrier-Grade NAT range (100.64.0.0/10). +// Addresses in this range are used by CNI plugins (Cilium, Calico, etc.) for pod networking +// and are not suitable for direct peer-to-peer connectivity between hosts. +var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10") + // FilterFn is a function that filters out candidates based on the address. // If it returns true, the address is to be filtered. It also returns the prefix of matching route. type FilterFn func(address netip.Addr) (bool, netip.Prefix, error) @@ -175,6 +180,15 @@ func (u *UDPConn) performFilterCheck(addr net.Addr) error { return fmt.Errorf("address %s is part of the NetBird network %s, refusing to write", addr, u.address) } + // Filter addresses in the RFC 6598 CGNAT range (100.64.0.0/10) that are not part of the + // NetBird WireGuard network. These addresses are commonly assigned by Kubernetes CNI plugins + // (Cilium, Calico, etc.) for pod networking and are not routable between hosts. + if cgnatPrefix.Contains(a) && !u.address.Network.Contains(a) { + u.addrCache.Store(addr.String(), true) + log.Infof("Address %s is in the CGNAT range (%s), likely a CNI pod address, refusing to write", addr, cgnatPrefix) + return fmt.Errorf("address %s is in the CGNAT range (%s), refusing to write", addr, cgnatPrefix) + } + if isRouted, prefix, err := u.filterFn(a); err != nil { log.Errorf("Failed to check if address %s is routed: %v", addr, err) } else { diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go index edd70fb20..425dcde33 100644 --- a/client/internal/peer/worker_ice.go +++ b/client/internal/peer/worker_ice.go @@ -165,6 +165,10 @@ func (w *WorkerICE) OnRemoteCandidate(candidate ice.Candidate, haRoutes route.HA return } + if candidateInCGNAT(candidate, w.config.WgConfig.WgInterface.Address().Network) { + return + } + if err := w.agent.AddRemoteCandidate(candidate); err != nil { w.log.Errorf("error while handling remote candidate") return @@ -362,6 +366,10 @@ func (w *WorkerICE) onICECandidate(candidate ice.Candidate) { return } + if candidateInCGNAT(candidate, w.config.WgConfig.WgInterface.Address().Network) { + return + } + // TODO: reported port is incorrect for CandidateTypeHost, makes understanding ICE use via logs confusing as port is ignored w.log.Debugf("discovered local candidate %s", candidate.String()) go func() { @@ -496,6 +504,11 @@ func extraSrflxCandidate(candidate ice.Candidate) (*ice.CandidateServerReflexive return ec, nil } +// cgnatPrefix is the RFC 6598 Carrier-Grade NAT range (100.64.0.0/10). +// Addresses in this range are used by CNI plugins (Cilium, Calico, etc.) for pod networking +// and are not suitable for direct peer-to-peer connectivity between hosts. +var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10") + func candidateViaRoutes(candidate ice.Candidate, clientRoutes route.HAMap) bool { addr, err := netip.ParseAddr(candidate.Address()) if err != nil { @@ -524,6 +537,32 @@ func candidateViaRoutes(candidate ice.Candidate, clientRoutes route.HAMap) bool return false } +// candidateInCGNAT checks if a candidate address falls within the RFC 6598 CGNAT range (100.64.0.0/10). +// These addresses are commonly used by Kubernetes CNI plugins (Cilium, Calico) for pod networking +// and are not routable between hosts, making them unsuitable as ICE candidates. +// The wgNetwork parameter is the NetBird WireGuard network prefix — if the candidate address is within +// this network, it is not filtered here (it's handled separately by the NetBird network check). +func candidateInCGNAT(candidate ice.Candidate, wgNetwork netip.Prefix) bool { + addr, err := netip.ParseAddr(candidate.Address()) + if err != nil { + return false + } + + if !cgnatPrefix.Contains(addr) { + return false + } + + // Don't filter if the address is within the WireGuard network itself — + // that's handled by the NetBird network membership check elsewhere. + if wgNetwork.IsValid() && wgNetwork.Contains(addr) { + return false + } + + log.Debugf("Ignoring candidate [%s], its address %s is in the CGNAT range (%s) likely assigned by a CNI plugin", + candidate.String(), addr, cgnatPrefix) + return true +} + func isRelayCandidate(candidate ice.Candidate) bool { return candidate.Type() == ice.CandidateTypeRelay } diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index b27f1932f..f509147bb 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -42,6 +42,8 @@ const ( var DefaultInterfaceBlacklist = []string{ iface.WgInterfaceDefault, "wt", "utun", "tun0", "zt", "ZeroTier", "wg", "ts", "Tailscale", "tailscale", "docker", "veth", "br-", "lo", + // Kubernetes CNI interfaces + "cilium_", "cilium", "lxc", "cali", "flannel", "cni", "weave", } // ConfigInput carries configuration changes to the client