diff options
| -rw-r--r-- | ipn/ipnauth/ipnauth.go | 95 | ||||
| -rw-r--r-- | ipn/ipnserver/server.go | 30 | ||||
| -rw-r--r-- | ipn/localapi/localapi.go | 4 | ||||
| -rw-r--r-- | util/groupmember/groupmember.go | 23 |
4 files changed, 120 insertions, 32 deletions
diff --git a/ipn/ipnauth/ipnauth.go b/ipn/ipnauth/ipnauth.go index 5dc2e2768..d16bf1123 100644 --- a/ipn/ipnauth/ipnauth.go +++ b/ipn/ipnauth/ipnauth.go @@ -5,15 +5,20 @@ package ipnauth import ( + "bufio" "errors" "fmt" "io" + "log" "net" "net/netip" "os" "os/user" + "path/filepath" "runtime" + "slices" "strconv" + "strings" "inet.af/peercred" "tailscale.com/envknob" @@ -191,21 +196,47 @@ func (ci *ConnIdentity) IsReadonlyConn(operatorUID string, logf logger.Logf) boo return ro } +// IsLocalAdmin reports whether the connected user has local administrative +// privileges on the host. This means root, or one of: +// +// - Windows: member of the Administrators group +// - macOS: member of the admin group +// - Linux: member of any sudoers group (usually "sudo" or "wheel") +func (ci *ConnIdentity) IsLocalAdmin() (bool, error) { + if ci.creds == nil { + return false, nil + } + uid, ok := ci.creds.UserID() + if !ok { + return false, nil + } + if uid == "0" { + return true, nil + } + return isLocalAdmin(uid) +} + func isLocalAdmin(uid string) (bool, error) { u, err := user.LookupId(uid) if err != nil { return false, err } - var adminGroup string + var adminGroups []string switch { case runtime.GOOS == "darwin": - adminGroup = "admin" + adminGroups = []string{"admin"} case distro.Get() == distro.QNAP: - adminGroup = "administrators" + adminGroups = []string{"administrators"} + case runtime.GOOS == "linux": + adminGroups, err = linuxSudoersGroups("/etc/sudoers") + log.Printf("========= linuxSudoersGroups(etc/sudoers): %q %v", adminGroups, err) + if err != nil { + return false, err + } default: return false, fmt.Errorf("no system admin group found") } - return groupmember.IsMemberOfGroup(adminGroup, u.Username) + return groupmember.IsMemberOfAnyGroup(u.Username, adminGroups...) } func peerPid(entries []netstat.Entry, la, ra netip.AddrPort) int { @@ -216,3 +247,59 @@ func peerPid(entries []netstat.Entry, la, ra netip.AddrPort) int { } return 0 } + +func linuxSudoersGroups(path string) ([]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + // We're looking for lines like: + // + // %wheel ALL=(ALL:ALL) ALL + // %sudo ALL=(ALL:ALL) ALL + // + // where group name after % is allowed to sudo as any user and run any + // command. Membership in these groups is equivalent to local admin. + s := bufio.NewScanner(f) + var groups []string + for s.Scan() { + line := s.Text() + if strings.HasPrefix(line, "@includedir ") { + dir := strings.TrimPrefix(line, "@includedir ") + paths, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + for _, p := range paths { + if !p.Type().IsRegular() { + continue + } + incGroups, err := linuxSudoersGroups(filepath.Join(dir, p.Name())) + log.Printf("========= linuxSudoersGroups(%q): %q %v", filepath.Join(dir, p.Name()), incGroups, err) + if err != nil { + return nil, err + } + groups = append(groups, incGroups...) + } + continue + } + if !strings.HasPrefix(line, "%") { + continue + } + parts := strings.SplitN(line, " ", 2) + if len(parts) != 2 { + continue + } + if !slices.Contains([]string{"ALL=(ALL:ALL) ALL", "ALL=(ALL) ALL"}, parts[1]) { + continue + } + group := strings.TrimPrefix(parts[0], "%") + if group != "" { + groups = append(groups, group) + } + } + + return groups, s.Err() +} diff --git a/ipn/ipnserver/server.go b/ipn/ipnserver/server.go index 755919275..9cdb90905 100644 --- a/ipn/ipnserver/server.go +++ b/ipn/ipnserver/server.go @@ -202,7 +202,10 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { lah := localapi.NewHandler(lb, s.logf, s.netMon, s.backendLogID) lah.PermitRead, lah.PermitWrite = s.localAPIPermissions(ci) lah.PermitCert = s.connCanFetchCerts(ci) - lah.CallerIsLocalAdmin = s.connIsLocalAdmin(ci) + lah.CallerIsLocalAdmin, err = ci.IsLocalAdmin() + if err != nil { + s.logf("IsLocalAdmin: %v", err) + } lah.ServeHTTP(w, r) return } @@ -364,31 +367,6 @@ func (s *Server) connCanFetchCerts(ci *ipnauth.ConnIdentity) bool { return false } -// connIsLocalAdmin reports whether ci has administrative access to the local -// machine, for whatever that means with respect to the current OS. -// -// This returns true only on Windows machines when the client user is a -// member of the built-in Administrators group (but not necessarily elevated). -// This is useful because, on Windows, tailscaled itself always runs with -// elevated rights: we want to avoid privilege escalation for certain mutative operations. -func (s *Server) connIsLocalAdmin(ci *ipnauth.ConnIdentity) bool { - tok, err := ci.WindowsToken() - if err != nil { - if !errors.Is(err, ipnauth.ErrNotImplemented) { - s.logf("ipnauth.ConnIdentity.WindowsToken() error: %v", err) - } - return false - } - defer tok.Close() - - isAdmin, err := tok.IsAdministrator() - if err != nil { - s.logf("ipnauth.WindowsToken.IsAdministrator() error: %v", err) - return false - } - return isAdmin -} - // addActiveHTTPRequest adds c to the server's list of active HTTP requests. // // If the returned error may be of type inUseOtherUserError. diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 83df7ef0e..195c2e31e 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -921,7 +921,7 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) { // TODO: roll-up this Windows-specific check into either PermitWrite // or a global admin escalation check. if shouldDenyServeConfigForGOOSAndUserContext(runtime.GOOS, configIn, h) { - http.Error(w, "must be a Windows local admin to serve a path", http.StatusUnauthorized) + http.Error(w, "must be a local admin to serve a path", http.StatusUnauthorized) return } @@ -941,7 +941,7 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) { } func shouldDenyServeConfigForGOOSAndUserContext(goos string, configIn *ipn.ServeConfig, h *Handler) bool { - if goos != "windows" { + if !slices.Contains([]string{"windows", "linux"}, goos) { return false } if !configIn.HasPathHandler() { diff --git a/util/groupmember/groupmember.go b/util/groupmember/groupmember.go index d60416816..672d2baee 100644 --- a/util/groupmember/groupmember.go +++ b/util/groupmember/groupmember.go @@ -27,3 +27,26 @@ func IsMemberOfGroup(group, userName string) (bool, error) { } return slices.Contains(ugids, g.Gid), nil } + +// IsMemberOfAnyGroup reports whether the provided user is a member of the any +// of the provided system groups. +func IsMemberOfAnyGroup(userName string, groups ...string) (bool, error) { + u, err := user.Lookup(userName) + if err != nil { + return false, err + } + ugids, err := u.GroupIds() + if err != nil { + return false, err + } + for _, group := range groups { + g, err := user.LookupGroup(group) + if err != nil { + return false, err + } + if slices.Contains(ugids, g.Gid) { + return true, nil + } + } + return false, nil +} |
