Visitar URL original
Security fixes for Incus 7.5 by stgraber · Pull Request #4071 · lxc/incus · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions cmd/incus/utils_sftp.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,17 @@ func sftpCreateFile(sftpConn *sftp.Client, targetPath string, args incus.Instanc
return nil
}

// sftpRecursivePullFile pulls a remote path into targetDir and, for directories, its content.
// sftpRecursivePullFile pulls a remote path into targetDir.
func sftpRecursivePullFile(sftpConn *sftp.Client, fInfo os.FileInfo, source string, normalizedSource string, targetDir string, quiet bool, archive bool, dereference bool, createRoot bool) error {
// Symlinks created during this transfer, which a malicious server must not be able to make us follow.
symlinks := map[string]struct{}{}

return sftpRecursivePullEntry(sftpConn, fInfo, source, normalizedSource, targetDir, quiet, archive, dereference, createRoot, symlinks)
}

// sftpRecursivePullEntry pulls a remote path and, for directories, its content.
// Pre-existing symlinks in the target are followed like cp does, but never ones created by this transfer.
func sftpRecursivePullEntry(sftpConn *sftp.Client, fInfo os.FileInfo, source string, normalizedSource string, targetDir string, quiet bool, archive bool, dereference bool, createRoot bool, symlinks map[string]struct{}) error {
var fileType string
if fInfo.IsDir() {
fileType = "directory"
Expand All @@ -212,6 +221,17 @@ func sftpRecursivePullFile(sftpConn *sftp.Client, fInfo os.FileInfo, source stri

logger.Infof("Pulling %s from %s (%s)", target, normalizedSource, fileType)

// Never write through a symlink planted earlier in this same pull, replace it instead.
_, planted := symlinks[target]
if planted {
err := os.Remove(target)
if err != nil {
return err
}

delete(symlinks, target)
}

switch fileType {
case "directory":
// Keep the directory writable until its content has been pulled.
Expand Down Expand Up @@ -252,7 +272,7 @@ func sftpRecursivePullFile(sftpConn *sftp.Client, fInfo os.FileInfo, source stri
return err
}

err = sftpRecursivePullFile(sftpConn, nextInfo, nextP, nextP, target, quiet, archive, dereference, true)
err = sftpRecursivePullEntry(sftpConn, nextInfo, nextP, nextP, target, quiet, archive, dereference, true, symlinks)
if err != nil {
return err
}
Expand Down Expand Up @@ -335,6 +355,8 @@ func sftpRecursivePullFile(sftpConn *sftp.Client, fInfo os.FileInfo, source stri
return err
}

symlinks[target] = struct{}{}

if archive {
sftpSetLocalAttrs(target, fInfo)
}
Expand Down
8 changes: 8 additions & 0 deletions cmd/incusd/instances_post.go
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,14 @@ func instancesPost(d *Daemon, r *http.Request) response.Response {
// Special handling for instance refresh.
// For all other situations, we're headed towards the scheduler, but for this case, we can short circuit it.
if s.ServerClustered && !clusterNotification && req.Source.Type == "migration" && req.Source.Refresh {
// The regular path checks this through AllowInstanceCreation, which isn't run here.
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
return project.AllowInstanceMigrationSource(tx, targetProjectName, req)
})
if err != nil {
return response.SmartError(err)
}

client, err := cluster.ConnectIfInstanceIsRemote(s, targetProjectName, req.Name, r)
if err != nil && !response.IsNotFoundError(err) {
return response.SmartError(err)
Expand Down
141 changes: 113 additions & 28 deletions cmd/incusd/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,93 @@ func waitForOperations(ctx context.Context, clusterDB *db.Cluster, consoleShutdo

// API functions

// operationCheckTokenAccess restricts operations carrying server access
// tokens to callers allowed to create those tokens.
func operationCheckTokenAccess(s *state.State, r *http.Request, op *operations.Operation) error {
switch op.Type() {
case operationtype.CertificateAddToken:
return s.Authorizer.CheckPermission(r.Context(), r, auth.ObjectServer(), auth.EntitlementCanCreateCertificates)
case operationtype.ClusterJoinToken:
return s.Authorizer.CheckPermission(r.Context(), r, auth.ObjectServer(), auth.EntitlementCanEdit)
}

return nil
}

// operationCheckPermission checks the caller against the permission the
// operation requires on each of its resources.
func operationCheckPermission(s *state.State, r *http.Request, op *operations.Operation) error {
objectType, entitlement := op.Permission()
if objectType == "" {
return api.StatusErrorf(http.StatusForbidden, "Operation has no associated permission")
}

projectName := op.Project()
if projectName == "" {
projectName = api.ProjectDefaultName
}

for _, v := range op.Resources() {
for _, u := range v {
// When dealing with specific objects, get the arguments from the URL.
var pathArgs []string

if objectType != auth.ObjectTypeProject {
var err error

_, _, _, pathArgs, err = dbCluster.URLToEntityType(u.String())
if err != nil {
return fmt.Errorf("Unable to parse operation resource URL: %w", err)
}
}

// Check that the access is allowed.
object, err := auth.NewObject(objectType, projectName, pathArgs...)
if err != nil {
return fmt.Errorf("Unable to create authorization object for operation: %w", err)
}

err = s.Authorizer.CheckPermission(r.Context(), r, object, entitlement)
if err != nil {
return err
}
}
}

return nil
}

// operationCheckViewAccess allows viewing an operation to callers with
// can_view_operations on its project or with the permission the operation
// itself requires, so that users scoped to a single resource can follow
// their own operations.
func operationCheckViewAccess(s *state.State, r *http.Request, op *operations.Operation) error {
err := operationCheckTokenAccess(s, r, op)
if err != nil {
return err
}

projectName := op.Project()
if projectName == "" {
projectName = api.ProjectDefaultName
}

err = s.Authorizer.CheckPermission(r.Context(), r, auth.ObjectProject(projectName), auth.EntitlementCanViewOperations)
if err == nil {
return nil
}

if !api.StatusErrorCheck(err, http.StatusForbidden) {
return err
}

if operationCheckPermission(s, r, op) != nil {
return err
}

return nil
}

// swagger:operation GET /1.0/operations/{id} operations operation_get
//
// Get the operation state
Expand Down Expand Up @@ -191,6 +278,11 @@ func operationGet(d *Daemon, r *http.Request) response.Response {
// First check if the query is for a local operation from this node
op, err := operations.OperationGetInternal(id)
if err == nil {
err = operationCheckViewAccess(s, r, op)
if err != nil {
return response.SmartError(err)
}

_, body, err = op.Render()
if err != nil {
return response.SmartError(err)
Expand Down Expand Up @@ -277,33 +369,11 @@ func operationDelete(d *Daemon, r *http.Request) response.Response {
projectName = api.ProjectDefaultName
}

objectType, entitlement := op.Permission()
objectType, _ := op.Permission()
if objectType != "" {
for _, v := range op.Resources() {
for _, u := range v {
// When dealing with specific objects, get the arguments from the URL.
var pathArgs []string

if objectType != auth.ObjectTypeProject {
var err error

_, _, _, pathArgs, err = dbCluster.URLToEntityType(u.String())
if err != nil {
return response.InternalError(fmt.Errorf("Unable to parse operation resource URL: %w", err))
}
}

// Check that the access is allowed.
object, err := auth.NewObject(objectType, projectName, pathArgs...)
if err != nil {
return response.InternalError(fmt.Errorf("Unable to create authorization object for operation: %w", err))
}

err = s.Authorizer.CheckPermission(r.Context(), r, object, entitlement)
if err != nil {
return response.SmartError(err)
}
}
err = operationCheckPermission(s, r, op)
if err != nil {
return response.SmartError(err)
}
}

Expand Down Expand Up @@ -556,6 +626,10 @@ func operationsGet(d *Daemon, r *http.Request) response.Response {
continue
}

if operationCheckTokenAccess(s, r, v) != nil {
continue
}

status := strings.ToLower(v.Status().String())
_, ok := body[status]
if !ok {
Expand Down Expand Up @@ -584,6 +658,10 @@ func operationsGet(d *Daemon, r *http.Request) response.Response {
continue
}

if operationCheckTokenAccess(s, r, v) != nil {
continue
}

status := strings.ToLower(v.Status().String())
_, ok := body[status]
if !ok {
Expand Down Expand Up @@ -991,8 +1069,15 @@ func operationWaitGet(d *Daemon, r *http.Request) response.Response {
// First check if the query is for a local operation from this node
op, err := operations.OperationGetInternal(id)
if err == nil {
if secret != "" && !util.CompareSecret(op.Metadata()["secret"], secret) {
return response.Forbidden(nil)
if secret != "" {
if !util.CompareSecret(op.Metadata()["secret"], secret) {
return response.Forbidden(nil)
}
} else {
err = operationCheckViewAccess(s, r, op)
if err != nil {
return response.SmartError(err)
}
}

var ctx context.Context
Expand Down
4 changes: 2 additions & 2 deletions cmd/incusd/storage_buckets.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,15 @@ var storagePoolBucketCmd = APIEndpoint{
var storagePoolBucketKeysCmd = APIEndpoint{
Path: "storage-pools/{poolName}/buckets/{bucketName}/keys",

Get: APIEndpointAction{Handler: storagePoolBucketKeysGet, AccessHandler: allowPermission(auth.ObjectTypeStorageBucket, auth.EntitlementCanView, "poolName", "bucketName", "location")},
Get: APIEndpointAction{Handler: storagePoolBucketKeysGet, AccessHandler: allowPermission(auth.ObjectTypeStorageBucket, auth.EntitlementCanEdit, "poolName", "bucketName", "location")},
Post: APIEndpointAction{Handler: storagePoolBucketKeysPost, AccessHandler: allowPermission(auth.ObjectTypeStorageBucket, auth.EntitlementCanEdit, "poolName", "bucketName", "location")},
}

var storagePoolBucketKeyCmd = APIEndpoint{
Path: "storage-pools/{poolName}/buckets/{bucketName}/keys/{keyName}",

Delete: APIEndpointAction{Handler: storagePoolBucketKeyDelete, AccessHandler: allowPermission(auth.ObjectTypeStorageBucket, auth.EntitlementCanEdit, "poolName", "bucketName", "location")},
Get: APIEndpointAction{Handler: storagePoolBucketKeyGet, AccessHandler: allowPermission(auth.ObjectTypeStorageBucket, auth.EntitlementCanView, "poolName", "bucketName", "location")},
Get: APIEndpointAction{Handler: storagePoolBucketKeyGet, AccessHandler: allowPermission(auth.ObjectTypeStorageBucket, auth.EntitlementCanEdit, "poolName", "bucketName", "location")},
Put: APIEndpointAction{Handler: storagePoolBucketKeyPut, AccessHandler: allowPermission(auth.ObjectTypeStorageBucket, auth.EntitlementCanEdit, "poolName", "bucketName", "location")},
}

Expand Down
7 changes: 6 additions & 1 deletion cmd/incusd/storage_volumes.go
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,11 @@ func storagePoolVolumesPost(d *Daemon, r *http.Request) response.Response {
return clusterCopyCustomVolumeInternal(s, r, nodeAddress, projectName, poolName, &req)
}

// A request with a source volume is a copy even when the source type is omitted.
if req.Source.Type == "" && req.Source.Name != "" {
req.Source.Type = "copy"
}

switch req.Source.Type {
case "":
err = validateCreateConfig(req.Config)
Expand Down Expand Up @@ -2413,7 +2418,7 @@ func storagePoolVolumePut(d *Daemon, r *http.Request) response.Response {
if req.Config != nil || req.Restore == "" {
// Possibly check if project limits are honored.
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
return project.AllowVolumeUpdate(tx, projectName, volumeName, req, dbVolume.Config)
return project.AllowVolumeUpdate(tx, projectName, poolName, volumeName, req, dbVolume.Config)
})
if err != nil {
return response.SmartError(err)
Expand Down
29 changes: 29 additions & 0 deletions internal/linux/filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,32 @@ func GetMountinfo(path string) ([]string, error) {

return nil, errors.New("No mountinfo entry found")
}

// MountNoSymlinkFollow bind-mounts path over itself with nosymfollow set so that nothing writing
// under it can be redirected through a symlink. Returns a function undoing the mount.
func MountNoSymlinkFollow(path string) (func() error, error) {
err := unix.Mount(path, path, "", unix.MS_BIND, "")
if err != nil {
return nil, fmt.Errorf("Failed to bind mount %q: %w", path, err)
}

unmount := func() error {
return unix.Unmount(path, 0)
}

fd, err := unix.Open(path, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
_ = unmount()
return nil, fmt.Errorf("Failed to open %q: %w", path, err)
}

defer func() { _ = unix.Close(fd) }()

err = unix.MountSetattr(fd, "", unix.AT_EMPTY_PATH, &unix.MountAttr{Attr_set: unix.MOUNT_ATTR_NOSYMFOLLOW})
if err != nil {
_ = unmount()
return nil, fmt.Errorf("Failed to set nosymfollow on %q: %w", path, err)
}

return unmount, nil
}
8 changes: 8 additions & 0 deletions internal/rsync/rsync.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,14 @@ func Recv(path string, conn io.ReadWriteCloser, tracker *ioprogress.ProgressTrac

args = append(args, []string{".", path}...)

// Never follow symlinks under the target so the sender can't redirect writes outside of it.
unmount, err := linux.MountNoSymlinkFollow(path)
if err != nil {
return err
}

defer logger.WarnOnError(unmount, "Failed to unmount rsync target", logger.Ctx{"path": path})

cmd := exec.Command("rsync", args...)

// Call the wrapper if defined.
Expand Down
Loading
Loading