diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index abffa367f..554dc4ec8 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -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 diff --git a/cmd/tailscaled/depaware-min.txt b/cmd/tailscaled/depaware-min.txt index 2b6eaef32..65d235fca 100644 --- a/cmd/tailscaled/depaware-min.txt +++ b/cmd/tailscaled/depaware-min.txt @@ -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+ diff --git a/cmd/tailscaled/depaware-minbox.txt b/cmd/tailscaled/depaware-minbox.txt index 8804c6d2d..495291937 100644 --- a/cmd/tailscaled/depaware-minbox.txt +++ b/cmd/tailscaled/depaware-minbox.txt @@ -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+ diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 6d7749789..47ecff265 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -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+ diff --git a/cmd/tsidp/depaware.txt b/cmd/tsidp/depaware.txt index 216a49d7f..1f2ed7a46 100644 --- a/cmd/tsidp/depaware.txt +++ b/cmd/tsidp/depaware.txt @@ -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 diff --git a/feature/conn25/conn25.go b/feature/conn25/conn25.go index 83859751b..44c5035a8 100644 --- a/feature/conn25/conn25.go +++ b/feature/conn25/conn25.go @@ -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 diff --git a/feature/conn25/conn25_test.go b/feature/conn25/conn25_test.go index 08e3c88af..928446fd9 100644 --- a/feature/conn25/conn25_test.go +++ b/feature/conn25/conn25_test.go @@ -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{}}}, }, }, } diff --git a/feature/conn25/connector.go b/feature/conn25/connector.go index 9d5144d24..410eaedcd 100644 --- a/feature/conn25/connector.go +++ b/feature/conn25/connector.go @@ -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", diff --git a/ipn/ipnlocal/via.go b/ipn/ipnlocal/via.go index 1f6d423e9..21b35bbc6 100644 --- a/ipn/ipnlocal/via.go +++ b/ipn/ipnlocal/via.go @@ -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) } diff --git a/net/routelimits/routelimits.go b/net/routelimits/routelimits.go new file mode 100644 index 000000000..b3b64f1fb --- /dev/null +++ b/net/routelimits/routelimits.go @@ -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 +} diff --git a/net/routelimits/routelimits_test.go b/net/routelimits/routelimits_test.go new file mode 100644 index 000000000..0f708914a --- /dev/null +++ b/net/routelimits/routelimits_test.go @@ -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) + } + } +} diff --git a/tsnet/depaware.txt b/tsnet/depaware.txt index 28e1339b9..0dcb10650 100644 --- a/tsnet/depaware.txt +++ b/tsnet/depaware.txt @@ -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