feature/conn25, ipn/ipnlocal: use the new net/routelimits package for blocking host-scoped destinations

Similar to #20866 for 4via6, conn25 needs to block host-scoped
destinations. Due to the similarity between the two situations, the
matching is no longer in a 4via6-specific location. Since there is no
backwards compatibility to satisfy, there is no exception list for
conn25.

ipn/ipnlocal (for 4via6) and feature/conn25 now both use the new
package.

Updates tailscale/corp#50118
Updates tailscale/corp#46169

Change-Id: Ic3f9cccd2f5823a2ca48bd78607792b0fd6d7db9
Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
This commit is contained in:
Adrian Dewhurst authored and Adrian Dewhurst committed 2026-10-29 13:57:37 -04:00
1 parent 6630facd78
commit 7304dd42c1
12 files changed
+328 -88

No files matched your search

+1
View File
@@ -824,6 +824,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/net/proxymux from tailscale.com/tsnet
tailscale.com/net/routecheck from tailscale.com/client/local+
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal+
tailscale.com/net/routelimits from tailscale.com/ipn/ipnlocal
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
tailscale.com/net/socks5 from tailscale.com/tsnet
+1
View File
@@ -106,6 +106,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/net/ping from tailscale.com/net/netcheck+
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal
tailscale.com/net/routelimits from tailscale.com/ipn/ipnlocal
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal+
tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
+1
View File
@@ -123,6 +123,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/net/ping from tailscale.com/net/netcheck+
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal
tailscale.com/net/routelimits from tailscale.com/ipn/ipnlocal
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal+
tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
+1
View File
@@ -403,6 +403,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/net/proxymux from tailscale.com/cmd/tailscaled
tailscale.com/net/routecheck from tailscale.com/feature/routecheck+
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal+
tailscale.com/net/routelimits from tailscale.com/feature/conn25+
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal+
tailscale.com/net/routetable from tailscale.com/doctor/routetable
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock+
+1
View File
@@ -210,6 +210,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
tailscale.com/net/proxymux from tailscale.com/tsnet
tailscale.com/net/routecheck from tailscale.com/client/local+
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal+
tailscale.com/net/routelimits from tailscale.com/ipn/ipnlocal
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
tailscale.com/net/socks5 from tailscale.com/tsnet
+6
View File
@@ -571,6 +571,7 @@ const (
unknownAppNameMessage = "The App name in the request does not match a configured App"
missingAppPermissionMessage = "You do not have permission to use this App"
transitIPNotInPoolMessage = "The transit address is not in a configured transit IP pool"
disallowedDestinationMessage = "Destination address is in a disallowed address range"
)
// handleConnectorTransitIPRequest creates a ConnectorTransitIPResponse in response
@@ -707,6 +708,11 @@ const (
// which sees this has most likely allocated from a pool configuration that
// the connector has not received yet, or has already replaced.
TransitIPNotInPool = 7
// DisallowedDestination indicates that the destination IP address is not
// allowed (including but not limited to loopback, Tailscale, and link-local
// addresses).
DisallowedDestination = 8
)
// TransitIPResponse is the response to a TransitIPRequest
+137 -71
View File
@@ -6,6 +6,7 @@ package conn25
import (
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"net/netip"
@@ -60,47 +61,78 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
const appName = "TestApp"
const invalidAppName = "InvalidApp"
// Peer IPs
pipV4_1 := netip.MustParseAddr("100.101.101.101")
pipV4_2 := netip.MustParseAddr("100.101.101.102")
type v4OrV6 int
const (
v4 v4OrV6 = 1
v6 v4OrV6 = 2
)
pipV6_1 := netip.MustParseAddr("fd7a:115c:a1e0::101")
pipV6_3 := netip.MustParseAddr("fd7a:115c:a1e0::103")
ipGenerator := func(name string, first4, first6 netip.Addr) func(ver v4OrV6, idx int) netip.Addr {
if !first4.Is4() {
panic("first4 is not ipv4")
}
if !first6.Is6() {
panic("first6 is not ipv6")
}
// We don't want to deal with carries
maxAddrs := math.MaxUint8 - int(max(first4.As4()[3], first6.As16()[15]))
return func(ver v4OrV6, idx int) netip.Addr {
if idx < 0 || idx > maxAddrs {
panic(fmt.Sprintf("%s out of range", name))
}
switch ver {
case v4:
addr := first4.As4()
addr[3] += byte(idx)
return netip.AddrFrom4(addr)
case v6:
addr := first6.As16()
addr[15] += byte(idx)
return netip.AddrFrom16(addr)
}
panic("unknown address type")
}
}
// Peer IPs:
// v4 100.101.101.100 - 100.101.101.255
// v6 fd7a:115c:a1e0::0100 - fd7a:115c:a1e0::01ff
pip := ipGenerator("pip", netip.MustParseAddr("100.101.101.100"), netip.MustParseAddr("fd7a:115c:a1e0::0100"))
// Transit IPs, from the pools configured on the connector below.
tipV4_1 := netip.MustParseAddr("169.254.0.1")
tipV4_2 := netip.MustParseAddr("169.254.0.2")
tipV6_1 := netip.MustParseAddr("FE80::1")
// v4 169.254.0.0 - 169.254.0.255
// v6 fe80:: - fe80::ff
tip := ipGenerator("tip", netip.MustParseAddr("169.254.0.0"), netip.MustParseAddr("fe80::"))
// Transit IPs from outside of the configured pools.
tipV4Outside := netip.MustParseAddr("192.0.2.1")
tipV6Outside := netip.MustParseAddr("2001:db8::1")
// tipV4_1 written in its IPv4-in-IPv6 form; the same address as tipV4_1.
// tip(v4, 1) written in its IPv4-in-IPv6 form; the same address as tip(v4, 1).
tipV4_1In6 := netip.MustParseAddr("::ffff:169.254.0.1")
// Destination IPs
dipV4_1 := netip.MustParseAddr("10.0.0.1")
dipV4_2 := netip.MustParseAddr("10.0.0.2")
dipV4_3 := netip.MustParseAddr("10.0.0.3")
dipV6_1 := netip.MustParseAddr("fc00::1")
// v4 10.0.0.0 - 10.0.0.255
// v6 fc00:: - fc00::ff
dip := ipGenerator("dip", netip.MustParseAddr("10.0.0.0"), netip.MustParseAddr("fc00::"))
// Peer nodes
peerV4V6 := (&tailcfg.Node{
ID: tailcfg.NodeID(1),
Addresses: []netip.Prefix{netip.PrefixFrom(pipV4_1, 32), netip.PrefixFrom(pipV6_1, 128)},
Addresses: []netip.Prefix{netip.PrefixFrom(pip(v4, 1), 32), netip.PrefixFrom(pip(v6, 1), 128)},
}).View()
peerV4Only := (&tailcfg.Node{
ID: tailcfg.NodeID(2),
Addresses: []netip.Prefix{netip.PrefixFrom(pipV4_2, 32)},
Addresses: []netip.Prefix{netip.PrefixFrom(pip(v4, 2), 32)},
}).View()
peerV6Only := (&tailcfg.Node{
ID: tailcfg.NodeID(3),
Addresses: []netip.Prefix{netip.PrefixFrom(pipV6_3, 128)},
Addresses: []netip.Prefix{netip.PrefixFrom(pip(v6, 3), 128)},
}).View()
tests := []struct {
@@ -123,13 +155,13 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
name: "one-peer-one-req-ipv4",
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tip(v4, 1), DestinationIP: dip(v4, 1), App: appName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, dipV4_1}},
{{pip(v4, 2), tip(v4, 1), dip(v4, 1)}},
},
},
// Single peer, single request with success ipV6
@@ -137,13 +169,13 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
name: "one-peer-one-req-ipv6",
ctipReqPeers: []tailcfg.NodeView{peerV6Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV6_1, DestinationIP: dipV6_1, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tip(v6, 1), DestinationIP: dip(v6, 1), App: appName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
},
wantLookups: [][][]netip.Addr{
{{pipV6_3, tipV6_1, dipV6_1}},
{{pip(v6, 3), tip(v6, 1), dip(v6, 1)}},
},
},
// Single peer, multi request with success, ipV4
@@ -151,16 +183,16 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
name: "one-peer-multi-req-ipv4",
ctipReqPeers: []tailcfg.NodeView{peerV4Only, peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_2, DestinationIP: dipV4_2, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tip(v4, 1), DestinationIP: dip(v4, 1), App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tip(v4, 2), DestinationIP: dip(v4, 2), App: appName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, dipV4_1}},
{{pipV4_2, tipV4_2, dipV4_2}},
{{pip(v4, 2), tip(v4, 1), dip(v4, 1)}},
{{pip(v4, 2), tip(v4, 2), dip(v4, 2)}},
},
},
// Single peer, multi request remap tip, ipV4
@@ -168,16 +200,16 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
name: "one-peer-remap-tip",
ctipReqPeers: []tailcfg.NodeView{peerV4Only, peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_2, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tip(v4, 1), DestinationIP: dip(v4, 1), App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tip(v4, 1), DestinationIP: dip(v4, 2), App: appName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, dipV4_1}},
{{pipV4_2, tipV4_1, dipV4_2}},
{{pip(v4, 2), tip(v4, 1), dip(v4, 1)}},
{{pip(v4, 2), tip(v4, 1), dip(v4, 2)}},
},
},
// Single peer, multi request with success, ipV4 and ipV6
@@ -185,16 +217,16 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
name: "one-peer-multi-req-ipv4-ipv6",
ctipReqPeers: []tailcfg.NodeView{peerV4V6, peerV4V6},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tipV6_1, DestinationIP: dipV6_1, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tip(v4, 1), DestinationIP: dip(v4, 1), App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tip(v6, 1), DestinationIP: dip(v6, 1), App: appName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_1, tipV4_1, dipV4_1}},
{{pipV4_1, tipV4_1, dipV4_1}, {pipV6_1, tipV6_1, dipV6_1}, {pipV4_1, tipV6_1, netip.Addr{}}},
{{pip(v4, 1), tip(v4, 1), dip(v4, 1)}},
{{pip(v4, 1), tip(v4, 1), dip(v4, 1)}, {pip(v6, 1), tip(v6, 1), dip(v6, 1)}, {pip(v4, 1), tip(v6, 1), netip.Addr{}}},
},
},
// Single peer, multi map with success, ipV4
@@ -203,15 +235,15 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{
{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName},
{TransitIP: tipV4_2, DestinationIP: dipV4_2, App: appName},
{TransitIP: tip(v4, 1), DestinationIP: dip(v4, 1), App: appName},
{TransitIP: tip(v4, 2), DestinationIP: dip(v4, 2), App: appName},
}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}, {Code: OK, Message: ""}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, dipV4_1}, {pipV4_2, tipV4_2, dipV4_2}},
{{pip(v4, 2), tip(v4, 1), dip(v4, 1)}, {pip(v4, 2), tip(v4, 2), dip(v4, 2)}},
},
},
// Single peer, error reuse same tip in one request, ensure all non-dup requests are processed
@@ -220,9 +252,9 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{
{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName},
{TransitIP: tipV4_1, DestinationIP: dipV4_2, App: appName},
{TransitIP: tipV4_2, DestinationIP: dipV4_3, App: appName},
{TransitIP: tip(v4, 1), DestinationIP: dip(v4, 1), App: appName},
{TransitIP: tip(v4, 1), DestinationIP: dip(v4, 2), App: appName},
{TransitIP: tip(v4, 2), DestinationIP: dip(v4, 3), App: appName},
}},
},
wants: []ConnectorTransitIPResponse{
@@ -235,7 +267,7 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, dipV4_1}, {pipV4_2, tipV4_2, dipV4_3}},
{{pip(v4, 2), tip(v4, 1), dip(v4, 1)}, {pip(v4, 2), tip(v4, 2), dip(v4, 3)}},
},
},
// Multi peer, success reuse same tip in one request
@@ -243,16 +275,16 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
name: "multi-peer-duplicate-tip",
ctipReqPeers: []tailcfg.NodeView{peerV4V6, peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_2, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tip(v4, 1), DestinationIP: dip(v4, 1), App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tip(v4, 1), DestinationIP: dip(v4, 2), App: appName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_1, tipV4_1, dipV4_1}},
{{pipV4_1, tipV4_1, dipV4_1}, {pipV4_2, tipV4_1, dipV4_2}},
{{pip(v4, 1), tip(v4, 1), dip(v4, 1)}},
{{pip(v4, 1), tip(v4, 1), dip(v4, 1)}, {pip(v4, 2), tip(v4, 1), dip(v4, 2)}},
},
},
// Single peer, multi map, multiple tip to same dip
@@ -261,15 +293,15 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{
{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName},
{TransitIP: tipV4_2, DestinationIP: dipV4_1, App: appName},
{TransitIP: tip(v4, 1), DestinationIP: dip(v4, 1), App: appName},
{TransitIP: tip(v4, 2), DestinationIP: dip(v4, 1), App: appName},
}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}, {Code: OK, Message: ""}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, dipV4_1}, {pipV4_2, tipV4_2, dipV4_1}},
{{pip(v4, 2), tip(v4, 1), dip(v4, 1)}, {pip(v4, 2), tip(v4, 2), dip(v4, 1)}},
},
},
// Single peer, ipv4 tip, no ipv4 pip, but ipv6 tip works
@@ -278,8 +310,8 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
ctipReqPeers: []tailcfg.NodeView{peerV6Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{
{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName},
{TransitIP: tipV6_1, DestinationIP: dipV6_1, App: appName},
{TransitIP: tip(v4, 1), DestinationIP: dip(v4, 1), App: appName},
{TransitIP: tip(v6, 1), DestinationIP: dip(v6, 1), App: appName},
}},
},
wants: []ConnectorTransitIPResponse{
@@ -289,7 +321,7 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
}},
},
wantLookups: [][][]netip.Addr{
{{pipV6_3, tipV4_1, netip.Addr{}}, {pipV6_3, tipV6_1, dipV6_1}},
{{pip(v6, 3), tip(v4, 1), netip.Addr{}}, {pip(v6, 3), tip(v6, 1), dip(v6, 1)}},
},
},
// Single peer, ipv6 tip, no ipv6 pip, but ipv4 tip works
@@ -298,8 +330,8 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{
{TransitIP: tipV6_1, DestinationIP: dipV6_1, App: appName},
{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName},
{TransitIP: tip(v6, 1), DestinationIP: dip(v6, 1), App: appName},
{TransitIP: tip(v4, 1), DestinationIP: dip(v4, 1), App: appName},
}},
},
wants: []ConnectorTransitIPResponse{
@@ -309,7 +341,7 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV6_1, netip.Addr{}}, {pipV4_2, tipV4_1, dipV4_1}},
{{pip(v4, 2), tip(v6, 1), netip.Addr{}}, {pip(v4, 2), tip(v4, 1), dip(v4, 1)}},
},
},
// Single peer, mismatched transit and destination ips
@@ -317,13 +349,47 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
name: "one-peer-mismatched-tip-dip",
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV6_1, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tip(v4, 1), DestinationIP: dip(v6, 1), App: appName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: AddrFamilyMismatch, Message: addrFamilyMismatchMessage}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, netip.Addr{}}},
{{pip(v4, 2), tip(v4, 1), netip.Addr{}}},
},
},
// Single peer, host-scoped destinations
{
name: "one-peer-host-scoped-dip",
ctipReqPeers: []tailcfg.NodeView{peerV4V6},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{
{TransitIP: tip(v4, 1), DestinationIP: netip.MustParseAddr("127.0.0.123"), App: appName},
{TransitIP: tip(v4, 2), DestinationIP: netip.MustParseAddr("169.254.255.255"), App: appName},
{TransitIP: tip(v4, 3), DestinationIP: netip.MustParseAddr("224.0.0.1"), App: appName},
{TransitIP: tip(v4, 4), DestinationIP: netip.IPv4Unspecified(), App: appName},
{TransitIP: tip(v4, 5), DestinationIP: netip.MustParseAddr("255.255.255.255"), App: appName},
{TransitIP: tip(v6, 1), DestinationIP: netip.MustParseAddr("::1"), App: appName},
{TransitIP: tip(v6, 2), DestinationIP: netip.MustParseAddr("fe80::1"), App: appName},
{TransitIP: tip(v6, 3), DestinationIP: netip.MustParseAddr("ff00::1"), App: appName},
{TransitIP: tip(v6, 4), DestinationIP: netip.IPv6Unspecified(), App: appName},
}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: slices.Repeat([]TransitIPResponse{{Code: DisallowedDestination, Message: disallowedDestinationMessage}}, 9)},
},
wantLookups: [][][]netip.Addr{
{
{pip(v4, 1), tip(v4, 1), netip.Addr{}},
{pip(v4, 1), tip(v4, 2), netip.Addr{}},
{pip(v4, 1), tip(v4, 3), netip.Addr{}},
{pip(v4, 1), tip(v4, 4), netip.Addr{}},
{pip(v4, 1), tip(v4, 5), netip.Addr{}},
{pip(v6, 1), tip(v6, 1), netip.Addr{}},
{pip(v6, 1), tip(v6, 2), netip.Addr{}},
{pip(v6, 1), tip(v6, 3), netip.Addr{}},
{pip(v6, 1), tip(v6, 4), netip.Addr{}},
},
},
},
// Single peer, invalid app name
@@ -331,13 +397,13 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
name: "one-peer-invalid-app",
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: invalidAppName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tip(v4, 1), DestinationIP: dip(v4, 1), App: invalidAppName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: UnknownAppName, Message: unknownAppNameMessage}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, netip.Addr{}}},
{{pip(v4, 2), tip(v4, 1), netip.Addr{}}},
},
},
// Missing PeerCap
@@ -345,21 +411,21 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
name: "missing-peercap",
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tip(v4, 1), DestinationIP: dip(v4, 1), App: appName}}},
},
missingPeerCap: true,
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: MissingAppPermission, Message: missingAppPermissionMessage}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, netip.Addr{}}},
{{pip(v4, 2), tip(v4, 1), netip.Addr{}}},
},
},
{
name: "invalid-app-with-peercap",
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: invalidAppName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tip(v4, 1), DestinationIP: dip(v4, 1), App: invalidAppName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: UnknownAppName, Message: unknownAppNameMessage}}},
@@ -372,7 +438,7 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
name: "invalid-app-without-peercap",
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1, DestinationIP: dipV4_1, App: invalidAppName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tip(v4, 1), DestinationIP: dip(v4, 1), App: invalidAppName}}},
},
missingPeerCap: true,
wants: []ConnectorTransitIPResponse{
@@ -387,13 +453,13 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
name: "one-peer-tip-not-in-pool-ipv4",
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4Outside, DestinationIP: dipV4_1, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4Outside, DestinationIP: dip(v4, 1), App: appName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: TransitIPNotInPool, Message: transitIPNotInPoolMessage}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4Outside, netip.Addr{}}},
{{pip(v4, 2), tipV4Outside, netip.Addr{}}},
},
},
// Single peer, ipv6 transit IP from outside the configured pool
@@ -401,13 +467,13 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
name: "one-peer-tip-not-in-pool-ipv6",
ctipReqPeers: []tailcfg.NodeView{peerV6Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV6Outside, DestinationIP: dipV6_1, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tipV6Outside, DestinationIP: dip(v6, 1), App: appName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: TransitIPNotInPool, Message: transitIPNotInPoolMessage}}},
},
wantLookups: [][][]netip.Addr{
{{pipV6_3, tipV6Outside, netip.Addr{}}},
{{pip(v6, 3), tipV6Outside, netip.Addr{}}},
},
},
// Single peer, an out of pool transit IP does not stop the other
@@ -417,8 +483,8 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{
{TransitIP: tipV4Outside, DestinationIP: dipV4_1, App: appName},
{TransitIP: tipV4_2, DestinationIP: dipV4_2, App: appName},
{TransitIP: tipV4Outside, DestinationIP: dip(v4, 1), App: appName},
{TransitIP: tip(v4, 2), DestinationIP: dip(v4, 2), App: appName},
}},
},
wants: []ConnectorTransitIPResponse{
@@ -428,7 +494,7 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4Outside, netip.Addr{}}, {pipV4_2, tipV4_2, dipV4_2}},
{{pip(v4, 2), tipV4Outside, netip.Addr{}}, {pip(v4, 2), tip(v4, 2), dip(v4, 2)}},
},
},
// Single peer, a transit IP in its IPv4-in-IPv6 form is canonicalized
@@ -437,13 +503,13 @@ func TestHandleConnectorTransitIPRequest(t *testing.T) {
name: "one-peer-tip-4in6",
ctipReqPeers: []tailcfg.NodeView{peerV4Only},
ctipReqs: []ConnectorTransitIPRequest{
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1In6, DestinationIP: dipV4_1, App: appName}}},
{TransitIPs: []TransitIPRequest{{TransitIP: tipV4_1In6, DestinationIP: dip(v4, 1), App: appName}}},
},
wants: []ConnectorTransitIPResponse{
{TransitIPs: []TransitIPResponse{{Code: OK, Message: ""}}},
},
wantLookups: [][][]netip.Addr{
{{pipV4_2, tipV4_1, dipV4_1}, {pipV4_2, tipV4_1In6, netip.Addr{}}},
{{pip(v4, 2), tip(v4, 1), dip(v4, 1)}, {pip(v4, 2), tipV4_1In6, netip.Addr{}}},
},
},
}
+5
View File
@@ -11,6 +11,7 @@ import (
"time"
"tailscale.com/net/packet"
"tailscale.com/net/routelimits"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/logger"
@@ -29,6 +30,10 @@ func (c *connector) handleTransitIPRequest(n tailcfg.NodeView, peerV4 netip.Addr
return TransitIPResponse{Code: AddrFamilyMismatch, Message: addrFamilyMismatchMessage}
}
if routelimits.IsDisallowed(tipr.DestinationIP) {
return TransitIPResponse{Code: DisallowedDestination, Message: disallowedDestinationMessage}
}
// The transit address has to come from a transit IP pool we're configured with.
if !c.transitIPInPool(tipr.TransitIP) {
c.logf("[Unexpected] peer attempt to map a transit IP outside of the configured pools: node: %s, IP: %v",
+2 -17
View File
@@ -13,13 +13,10 @@ import (
"go4.org/netipx"
"tailscale.com/envknob"
"tailscale.com/net/tsaddr"
"tailscale.com/net/routelimits"
"tailscale.com/util/clientmetric"
)
// v4BroadcastAddr is 255.255.255.255, the limited broadcast address.
var v4BroadcastAddr = netip.AddrFrom4([4]byte{255, 255, 255, 255})
// viaAdditionsEnv is the name of an environment variable that can be set to
// override the 4via6 host-scoped address restrictions. Only use this if you are
// certain that there is not a better solution (e.g. perhaps tailscale serve,
@@ -118,17 +115,5 @@ func viaTargetAllowed(ip netip.Addr) bool {
return true
}
}
if ip.IsLoopback() || ip.IsMulticast() || ip.IsUnspecified() || ip == v4BroadcastAddr {
return false
}
if tsaddr.IsTailscaleIP(ip) {
// A CGNAT-range target may route to tailscale0 or to a site LAN
// depending on the tailnet and OS (see shouldUseOneCGNATRoute);
// it's hard to detect when forwarding would be OK, so deny always
return false
}
if ip.IsLinkLocalUnicast() {
return false
}
return true
return !routelimits.IsDisallowed(ip)
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package routelimits performs tests to verify that mapped routing (4via6,
// conn25) operations do not allow communication that would normally be
// prevented by filters located elsewhere, such as
// [tailscale.com/wgengine/filter]. It also contains lists of
// potentially-sensitive cloud provider destinations.
package routelimits
import (
"net/netip"
"sync"
"tailscale.com/net/ipset"
"tailscale.com/net/tsaddr"
"tailscale.com/types/views"
)
// v4BroadcastAddr is 255.255.255.255, the limited broadcast address.
var v4BroadcastAddr = netip.AddrFrom4([4]byte{255, 255, 255, 255})
// isCloudMetadata returns true for IP addresses (v4 or v6) that are known to
// potentially contain cloud metadata. Most cloud providers document single
// addresses (/128) but sometimes they have multiple addresses, so all ULA IPv6
// addresses are recorded as their entire ULA prefix (/48).
var isCloudMetadata = sync.OnceValue(func() func(netip.Addr) bool {
prefixes := []netip.Prefix{
netip.MustParsePrefix("169.254.169.254/32"), // standard IPv4 address
netip.MustParsePrefix("fe80::a9fe:a9fe/128"), // commonly used link local IPv6 address
// AWS
// Standard IPv4 address
netip.MustParsePrefix("fd00:ec2::/48"),
// GCP
// Standard IPv4 address
netip.MustParsePrefix("fd20:ce::/48"),
// Scaleway
netip.MustParsePrefix("169.254.42.42/32"),
netip.MustParsePrefix("fd00:42::/48"),
// Akamai/Linode
// Standard IPv4 address
netip.MustParsePrefix("fd00:a9fe:a9fe::/48"),
// Common link local IPv6 address
// OCI (Oracle)
// Standard IPv4 address
netip.MustParsePrefix("fd00:c1::/48"),
// Azure
// Standard IPv4 address
// Platform services at 168.63.129.16/32 is OK because it does not allow
// IMDS access anymore and although it provides DNS access for a
// filtered list of internal resources, reverse DNS is unsupported. It
// can be excluded using other mechanisms if an administrator does not
// want it exposed.
// No IPv6 address
// Hetzner
// Standard IPv4 address
// Common link local IPv6 address
// Vultr, DigitalOcean
// Standard IPv4 address
// No IPv6 address
// Alibaba
netip.MustParsePrefix("100.100.100.200/32"),
}
return ipset.NewContainsIPFunc(views.SliceOf(prefixes))
})
// IsDisallowed reports whether addr is an IP address in an IP range
// that should not be allowed for various features that offer routing not
// protected by [tailscale.com/wgengine/filter.Filter] pre(): 4via6, conn25.
func IsDisallowed(addr netip.Addr) bool {
if addr.IsLoopback() || addr.IsMulticast() || addr.IsUnspecified() ||
addr == v4BroadcastAddr || addr.IsLinkLocalUnicast() || addr.Is4In6() {
return true
}
if tsaddr.IsTailscaleIP(addr) {
// A CGNAT-range target may route to tailscale0 or to a site LAN
// depending on the tailnet and OS (see shouldUseOneCGNATRoute);
// it's hard to detect when forwarding would be OK, so deny always
return true
}
if isCloudMetadata()(addr) {
return true
}
return false
}
+77
View File
@@ -0,0 +1,77 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package routelimits
import (
"net/netip"
"testing"
)
func TestIsDisallowedForRouting(t *testing.T) {
t.Parallel()
cases := []struct {
ip string
want bool
}{
{"10.0.0.1", false},
{"192.168.1.1", false},
{"8.8.8.8", false},
{"169.254.169.254", true}, // cloud instance metadata
{"169.254.0.1", true}, // other link-local
{"127.0.0.1", true}, // loopback
{"127.255.0.1", true},
{"10.9.4.99", false}, // normal LAN host
{"192.168.50.254", false}, // last routable host on a /24 still allowed
{"0.0.0.0", true}, // Linux connect() treats as localhost
{"255.255.255.255", true},
{"192.168.50.128", false},
{"10.100.200.5", false},
{"10.1.2.255", false}, // last octet 255 is not inherently broadcast
{"172.16.0.9", false},
{"192.168.50.63", false},
{"224.0.0.1", true}, // multicast
{"239.255.252.250", true},
{"10.20.30.40", false},
{"198.51.100.7", false},
{"169.254.169.1", true}, // other link-local
{"255.0.0.5", false},
{"10.9.8.7", false},
{"192.168.1.254", false},
{"10.0.255.200", false}, // host in a /16 whose last octet isn't 0/255
{"224.0.0.251", true},
{"100.64.9.8", true},
{"192.168.5.10", false},
{"172.16.255.254", false}, // last host of a /16 (last octet 254) allowed
{"127.1.2.3", true},
{"198.18.0.10", false},
{"239.255.255.250", true}, // SSDP multicast
{"192.168.50.128", false},
{"169.254.100.200", true}, // link-local
{"10.9.4.255", false}, // last octet 255 is not inherently broadcast
{"224.0.1.129", true},
{"8.20.30.40", false},
{"192.168.50.255", false}, // last octet 255 is not inherently broadcast
{"10.11.12.13", false},
{"239.1.2.3", true},
{"100.64.0.1", true}, // tailnet CGNAT: would proxy as this node
{"100.100.100.100", true},
{"::1", true},
{"::", true},
{"fe80::1", true},
{"fd00:ec2::254", true}, // aws IMDS
{"fd00:ec2::253", true}, // route53
{"fd20:ce::254", true}, // gcp metadata
{"fd7a:115c:a1e0::53", true}, // tailscale IPv6 service IP
{"fd7a:115c:a1e0:1:2:3:4:5", true}, // arbitrary tailscale ULA
{"fd7a:1234:5678::1", false}, // arbitrary non-tailscale ULA
{"2606:b740:49::1", false}, // arbitrary globally routable IPv6
}
for _, tc := range cases {
ip := netip.MustParseAddr(tc.ip)
if got := IsDisallowed(ip); got != tc.want {
t.Errorf("IsDisallowedForRouting(%v) = %v, want %v", ip, got, tc.want)
}
}
}
+1
View File
@@ -206,6 +206,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
tailscale.com/net/proxymux from tailscale.com/tsnet
tailscale.com/net/routecheck from tailscale.com/client/local+
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal+
tailscale.com/net/routelimits from tailscale.com/ipn/ipnlocal
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
tailscale.com/net/socks5 from tailscale.com/tsnet