diff --git a/api/utils/route.go b/api/utils/route.go index 03df193065978..1ca83926e4ee1 100644 --- a/api/utils/route.go +++ b/api/utils/route.go @@ -19,6 +19,7 @@ import ( "errors" "net" "slices" + "strings" "unicode/utf8" "github.com/google/uuid" @@ -49,6 +50,8 @@ type SSHRouteMatcherConfig struct { Resolver HostResolver // CaseInsensitive enabled case insensitive routing when true. CaseInsensitive bool + // DisableUnqualifiedLookups disables lookups for unqualified hostnames. + DisableUnqualifiedLookups bool } // HostResolver provides an interface matching the net.Resolver.LookupHost method. Typically @@ -88,7 +91,12 @@ func newSSHRouteMatcher(cfg SSHRouteMatcherConfig) SSHRouteMatcher { _, err := uuid.Parse(cfg.Host) dialByID := err == nil || aws.IsEC2NodeID(cfg.Host) - ips, _ := cfg.Resolver.LookupHost(context.Background(), cfg.Host) + var ips []string + if !(cfg.DisableUnqualifiedLookups && !strings.Contains(cfg.Host, ".")) { + // unqualified lookups are still on by default, but future versions of teleport may disable them as they tend + // to be responsible for the majority of all lookups generated by a teleport cluster and are of questionable utility. + ips, _ = cfg.Resolver.LookupHost(context.Background(), cfg.Host) + } return SSHRouteMatcher{ cfg: cfg, diff --git a/api/utils/route_test.go b/api/utils/route_test.go index 5de05efa673d8..ce971e04e6ea3 100644 --- a/api/utils/route_test.go +++ b/api/utils/route_test.go @@ -323,3 +323,66 @@ func TestSSHRouteMatcherScoring(t *testing.T) { }) } } + +type recordingHostResolver struct { + didLookup bool +} + +func (r *recordingHostResolver) LookupHost(ctx context.Context, host string) (addrs []string, err error) { + r.didLookup = true + return nil, nil +} + +// TestDisableUnqualifiedLookups verifies that unqualified lookups being disabled results +// in single-element/tld style hostname targets not being resolved. +func TestDisableUnqualifiedLookups(t *testing.T) { + tts := []struct { + desc string + target string + lookup bool + }{ + { + desc: "qualified hostname", + target: "example.com", + lookup: true, + }, + { + desc: "unqualified hostname", + target: "example", + lookup: false, + }, + { + desc: "localhost", + target: "localhost", + lookup: false, + }, + { + desc: "foo.localhost", + target: "foo.localhost", + lookup: true, + }, + { + desc: "uuid", + target: uuid.NewString(), + lookup: false, + }, + { + desc: "qualified uuid", + target: "foo." + uuid.NewString(), + lookup: true, + }, + } + + for _, tt := range tts { + t.Run(tt.desc, func(t *testing.T) { + resolver := &recordingHostResolver{} + _, err := NewSSHRouteMatcherFromConfig(SSHRouteMatcherConfig{ + Host: tt.target, + Resolver: resolver, + DisableUnqualifiedLookups: true, + }) + require.NoError(t, err) + require.Equal(t, tt.lookup, resolver.didLookup) + }) + } +} diff --git a/lib/auth/auth_with_roles.go b/lib/auth/auth_with_roles.go index c306fa3c75aae..103561dcc5ce3 100644 --- a/lib/auth/auth_with_roles.go +++ b/lib/auth/auth_with_roles.go @@ -22,6 +22,7 @@ import ( "context" "fmt" "net/url" + "os" "slices" "strings" "time" @@ -1539,6 +1540,8 @@ func (a *ServerWithRoles) authContextForSearch(ctx context.Context, req *proto.L return extendedContext, nil } +var disableUnqualifiedLookups = os.Getenv("TELEPORT_UNSTABLE_DISABLE_UNQUALIFIED_LOOKUPS") == "yes" + // GetSSHTargets gets all servers that would match an equivalent ssh dial request. Note that this method // returns all resources directly accessible to the user *and* all resources available via 'SearchAsRoles', // which is what we want when handling things like ambiguous host errors and resource-based access requests, @@ -1551,7 +1554,15 @@ func (a *ServerWithRoles) GetSSHTargets(ctx context.Context, req *proto.GetSSHTa caseInsensitiveRouting = cfg.GetCaseInsensitiveRouting() } - matcher := apiutils.NewSSHRouteMatcher(req.Host, req.Port, caseInsensitiveRouting) + matcher, err := apiutils.NewSSHRouteMatcherFromConfig(apiutils.SSHRouteMatcherConfig{ + Host: req.Host, + Port: req.Port, + CaseInsensitive: caseInsensitiveRouting, + DisableUnqualifiedLookups: disableUnqualifiedLookups, + }) + if err != nil { + return nil, trace.Wrap(err) + } lreq := proto.ListResourcesRequest{ ResourceType: types.KindNode, diff --git a/lib/proxy/router.go b/lib/proxy/router.go index ddfb6d7b5f980..d9f37d2b6f716 100644 --- a/lib/proxy/router.go +++ b/lib/proxy/router.go @@ -24,6 +24,7 @@ import ( "errors" "fmt" "net" + "os" "sync" "github.com/gravitational/trace" @@ -417,6 +418,8 @@ func getServer(ctx context.Context, host, port string, site site) (types.Server, return getServerWithResolver(ctx, host, port, site, nil /* use default resolver */) } +var disableUnqualifiedLookups = os.Getenv("TELEPORT_UNSTABLE_DISABLE_UNQUALIFIED_LOOKUPS") == "yes" + // getServerWithResolver attempts to locate a node matching the provided host and port in // the provided site. The resolver argument is used in certain tests to mock DNS resolution // and can generally be left nil. @@ -433,10 +436,11 @@ func getServerWithResolver(ctx context.Context, host, port string, site site, re } routeMatcher, err := apiutils.NewSSHRouteMatcherFromConfig(apiutils.SSHRouteMatcherConfig{ - Host: host, - Port: port, - CaseInsensitive: caseInsensitiveRouting, - Resolver: resolver, + Host: host, + Port: port, + CaseInsensitive: caseInsensitiveRouting, + Resolver: resolver, + DisableUnqualifiedLookups: disableUnqualifiedLookups, }) if err != nil { return nil, trace.Wrap(err)