[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
This commit is contained in:
Zoltán Papp
2026-03-09 16:30:11 +01:00
parent 11eb725ac8
commit 09da089a90
3 changed files with 55 additions and 0 deletions

View File

@@ -22,6 +22,11 @@ import (
"github.com/netbirdio/netbird/client/iface/wgaddr" "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. // 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. // 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) 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) 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 { if isRouted, prefix, err := u.filterFn(a); err != nil {
log.Errorf("Failed to check if address %s is routed: %v", addr, err) log.Errorf("Failed to check if address %s is routed: %v", addr, err)
} else { } else {

View File

@@ -165,6 +165,10 @@ func (w *WorkerICE) OnRemoteCandidate(candidate ice.Candidate, haRoutes route.HA
return return
} }
if candidateInCGNAT(candidate, w.config.WgConfig.WgInterface.Address().Network) {
return
}
if err := w.agent.AddRemoteCandidate(candidate); err != nil { if err := w.agent.AddRemoteCandidate(candidate); err != nil {
w.log.Errorf("error while handling remote candidate") w.log.Errorf("error while handling remote candidate")
return return
@@ -362,6 +366,10 @@ func (w *WorkerICE) onICECandidate(candidate ice.Candidate) {
return 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 // 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()) w.log.Debugf("discovered local candidate %s", candidate.String())
go func() { go func() {
@@ -496,6 +504,11 @@ func extraSrflxCandidate(candidate ice.Candidate) (*ice.CandidateServerReflexive
return ec, nil 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 { func candidateViaRoutes(candidate ice.Candidate, clientRoutes route.HAMap) bool {
addr, err := netip.ParseAddr(candidate.Address()) addr, err := netip.ParseAddr(candidate.Address())
if err != nil { if err != nil {
@@ -524,6 +537,32 @@ func candidateViaRoutes(candidate ice.Candidate, clientRoutes route.HAMap) bool
return false 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 { func isRelayCandidate(candidate ice.Candidate) bool {
return candidate.Type() == ice.CandidateTypeRelay return candidate.Type() == ice.CandidateTypeRelay
} }

View File

@@ -42,6 +42,8 @@ const (
var DefaultInterfaceBlacklist = []string{ var DefaultInterfaceBlacklist = []string{
iface.WgInterfaceDefault, "wt", "utun", "tun0", "zt", "ZeroTier", "wg", "ts", iface.WgInterfaceDefault, "wt", "utun", "tun0", "zt", "ZeroTier", "wg", "ts",
"Tailscale", "tailscale", "docker", "veth", "br-", "lo", "Tailscale", "tailscale", "docker", "veth", "br-", "lo",
// Kubernetes CNI interfaces
"cilium_", "cilium", "lxc", "cali", "flannel", "cni", "weave",
} }
// ConfigInput carries configuration changes to the client // ConfigInput carries configuration changes to the client