diff --git a/cmd/incusd/daemon.go b/cmd/incusd/daemon.go index 62c15cf7f2..a6f6e11b19 100644 --- a/cmd/incusd/daemon.go +++ b/cmd/incusd/daemon.go @@ -326,12 +326,18 @@ func allowPermission(objectType auth.ObjectType, entitlement auth.Entitlement, m } // Expansion function to deal with project inheritance. + var expandProjectErr error expandProject := func(projectName string) string { // Object types that aren't part of projects. if slices.Contains([]auth.ObjectType{auth.ObjectTypeUser, auth.ObjectTypeServer, auth.ObjectTypeCertificate, auth.ObjectTypeStoragePool, auth.ObjectTypeNetworkIntegration}, objectType) { return projectName } + // Object types that are always addressed in the requested project. + if slices.Contains([]auth.ObjectType{auth.ObjectTypeProject, auth.ObjectTypeInstance}, objectType) { + return projectName + } + // Load the project. var p *api.Project err := d.db.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { @@ -352,24 +358,26 @@ func allowPermission(objectType auth.ObjectType, entitlement auth.Entitlement, m } if objectType == auth.ObjectTypeProfile { - projectName = project.ProfileProjectFromRecord(p) + return project.ProfileProjectFromRecord(p) } else if objectType == auth.ObjectTypeStorageBucket { - projectName = project.StorageBucketProjectFromRecord(p) + return project.StorageBucketProjectFromRecord(p) } else if objectType == auth.ObjectTypeStorageVolume { dbVolType, err := storagePools.VolumeTypeNameToDBType(muxVars[1]) if err != nil { return projectName } - projectName = project.StorageVolumeProjectFromRecord(p, dbVolType) + return project.StorageVolumeProjectFromRecord(p, dbVolType) } else if objectType == auth.ObjectTypeNetworkZone { - projectName = project.NetworkZoneProjectFromRecord(p) + return project.NetworkZoneProjectFromRecord(p) } else if slices.Contains([]auth.ObjectType{auth.ObjectTypeImage, auth.ObjectTypeImageAlias}, objectType) { - projectName = project.ImageProjectFromRecord(p) - } else if slices.Contains([]auth.ObjectType{auth.ObjectTypeNetwork, auth.ObjectTypeNetworkACL}, objectType) { - projectName = project.NetworkProjectFromRecord(p) + return project.ImageProjectFromRecord(p) + } else if slices.Contains([]auth.ObjectType{auth.ObjectTypeNetwork, auth.ObjectTypeNetworkACL, auth.ObjectTypeNetworkAddressSet}, objectType) { + return project.NetworkProjectFromRecord(p) } + // Fail closed rather than defaulting to the requested project, which could bypass confinement. + expandProjectErr = fmt.Errorf("No project expansion defined for object type %q", objectType) return projectName } @@ -450,6 +458,10 @@ func allowPermission(objectType auth.ObjectType, entitlement auth.Entitlement, m return response.InternalError(fmt.Errorf("Failed to create authentication object: %w", err)) } + if expandProjectErr != nil { + return response.InternalError(fmt.Errorf("Failed to expand project for authorization: %w", expandProjectErr)) + } + s := d.State() // Validate whether the user has the needed permission diff --git a/cmd/incusd/daemon_images.go b/cmd/incusd/daemon_images.go index 171f37d616..0085d53a6d 100644 --- a/cmd/incusd/daemon_images.go +++ b/cmd/incusd/daemon_images.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "path/filepath" + "regexp" "slices" "time" @@ -344,13 +345,12 @@ func imageDownload(ctx context.Context, r *http.Request, s *state.State, op *ope logger.Info("Downloading image", ctxMap) - // For the direct protocol the fingerprint is caller-controlled, so validate - // it to avoid path traversal when used as a file name. - if protocol == "direct" { - err = validate.IsSHA256(fp) - if err != nil { - return nil, false, errors.New("Invalid image fingerprint") - } + // The fingerprint is used as a file name, so reject anything that isn't a + // partial or full hex fingerprint to avoid path traversal. It's re-validated + // as a full SHA-256 once expanded from the remote below. + match, _ := regexp.MatchString("^[0-9a-f]{1,64}$", fp) + if !match { + return nil, false, errors.New("Invalid image fingerprint") } // Cleanup any leftover from a past attempt @@ -620,6 +620,13 @@ func imageDownload(ctx context.Context, r *http.Request, s *state.State, op *ope // Image is in the DB now, don't wipe on-disk files on failure failure = false + // Re-validate the fingerprint as it may have been updated from the remote + // server's response, and it is used as a file name below. + err = validate.IsSHA256(fp) + if err != nil { + return nil, false, errors.New("Invalid image fingerprint") + } + // Check if the image path changed (private images) newDestName := filepath.Join(destDir, fp) if newDestName != destName { diff --git a/cmd/incusd/instance_logs.go b/cmd/incusd/instance_logs.go index 6f8c54b960..1d49dbddbd 100644 --- a/cmd/incusd/instance_logs.go +++ b/cmd/incusd/instance_logs.go @@ -3,6 +3,7 @@ package main import ( "errors" "fmt" + "io/fs" "net/http" "os" "path/filepath" @@ -439,8 +440,16 @@ func instanceExecOutputsGet(d *Daemon, r *http.Request) response.Response { defer logger.WarnOnError(func() error { return pool.UnmountInstance(inst, nil) }, "Failed to unmount instance") + // Confine access to the exec output directory to avoid following symlinks. + root, err := os.OpenRoot(inst.ExecOutputPath()) + if err != nil { + return response.SmartError(err) + } + + defer logger.WarnOnError(root.Close, "Failed to close exec output root") + // Read exec record-output files - dents, err := os.ReadDir(inst.ExecOutputPath()) + dents, err := fs.ReadDir(root.FS(), ".") if err != nil { return response.SmartError(err) } @@ -554,13 +563,40 @@ func instanceExecOutputGet(d *Daemon, r *http.Request) response.Response { } reverter.Add(func() { _ = pool.UnmountInstance(inst, nil) }) + + // Confine access to the exec output directory to avoid following symlinks. + root, err := os.OpenRoot(inst.ExecOutputPath()) + if err != nil { + return response.SmartError(err) + } + + reverter.Add(func() { _ = root.Close() }) + + f, err := root.Open(file) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return response.NotFound(fmt.Errorf("Exec record-output file %q not found", file)) + } + + return response.SmartError(err) + } + + reverter.Add(func() { _ = f.Close() }) + + fi, err := f.Stat() + if err != nil { + return response.SmartError(err) + } + cleanup := reverter.Clone() reverter.Success() ent := response.FileResponseEntry{ - Path: filepath.Join(inst.ExecOutputPath(), file), - Filename: file, - Cleanup: cleanup.Fail, + File: f, + FileSize: fi.Size(), + FileModified: fi.ModTime(), + Filename: file, + Cleanup: cleanup.Fail, } s.Events.SendLifecycle(projectName, lifecycle.InstanceLogRetrieved.Event(file, inst, request.CreateRequestor(r), nil)) @@ -657,7 +693,15 @@ func instanceExecOutputDelete(d *Daemon, r *http.Request) response.Response { defer logger.WarnOnError(func() error { return pool.UnmountInstance(inst, nil) }, "Failed to unmount instance") - err = os.Remove(filepath.Join(inst.ExecOutputPath(), file)) + // Confine access to the exec output directory to avoid following symlinks. + root, err := os.OpenRoot(inst.ExecOutputPath()) + if err != nil { + return response.SmartError(err) + } + + defer logger.WarnOnError(root.Close, "Failed to close exec output root") + + err = root.Remove(file) if err != nil { return response.SmartError(err) } diff --git a/cmd/incusd/instance_metadata.go b/cmd/incusd/instance_metadata.go index e0a0efc431..5b7768b25f 100644 --- a/cmd/incusd/instance_metadata.go +++ b/cmd/incusd/instance_metadata.go @@ -119,15 +119,21 @@ func instanceMetadataGet(d *Daemon, r *http.Request) response.Response { defer logger.WarnOnError(func() error { return storagePools.InstanceUnmount(pool, c, nil) }, "Failed to unmount instance") - // If missing, just return empty result - metadataPath := filepath.Join(c.Path(), "metadata.yaml") - if !util.PathExists(metadataPath) { - return response.SyncResponse(true, api.ImageMetadata{}) + // Confine metadata access to the instance directory. + root, err := os.OpenRoot(c.Path()) + if err != nil { + return response.SmartError(err) } - // Read the metadata - metadataFile, err := os.Open(metadataPath) + defer logger.WarnOnError(root.Close, "Failed to close instance root") + + // Read the metadata (if missing, just return empty result). + metadataFile, err := root.Open("metadata.yaml") if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return response.SyncResponse(true, api.ImageMetadata{}) + } + return response.InternalError(err) } @@ -235,15 +241,22 @@ func instanceMetadataPatch(d *Daemon, r *http.Request) response.Response { defer logger.WarnOnError(func() error { return storagePools.InstanceUnmount(pool, inst, nil) }, "Failed to unmount instance") - // Read the existing data. - metadataPath := filepath.Join(inst.Path(), "metadata.yaml") - metadata := api.ImageMetadata{} - if util.PathExists(metadataPath) { - metadataFile, err := os.Open(metadataPath) - if err != nil { - return response.InternalError(err) - } + // Confine metadata access to the instance directory. + root, err := os.OpenRoot(inst.Path()) + if err != nil { + return response.SmartError(err) + } + defer logger.WarnOnError(root.Close, "Failed to close instance root") + + // Read the existing data. + metadata := api.ImageMetadata{} + metadataFile, err := root.Open("metadata.yaml") + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return response.InternalError(err) + } + + if err == nil { defer logger.WarnOnError(metadataFile.Close, "Failed to close metadata file") data, err := io.ReadAll(metadataFile) @@ -376,9 +389,15 @@ func doInstanceMetadataUpdate(s *state.State, inst instance.Instance, metadata a return response.BadRequest(err) } - // Update the metadata. - metadataPath := filepath.Join(inst.Path(), "metadata.yaml") - err = os.WriteFile(metadataPath, data, 0o644) + // Update the metadata (confined to the instance directory). + root, err := os.OpenRoot(inst.Path()) + if err != nil { + return response.InternalError(err) + } + + defer logger.WarnOnError(root.Close, "Failed to close instance root") + + err = root.WriteFile("metadata.yaml", data, 0o644) if err != nil { return response.InternalError(err) } diff --git a/cmd/incusd/instance_post.go b/cmd/incusd/instance_post.go index fe02cbd2a6..9173109db5 100644 --- a/cmd/incusd/instance_post.go +++ b/cmd/incusd/instance_post.go @@ -647,6 +647,15 @@ func migrateInstance(ctx context.Context, s *state.State, inst instance.Instance targetInstInfo.Profiles = req.Profiles } + // Enforce project restrictions against the overridden config, as the migration + // request can otherwise set restricted keys (e.g. security.privileged, raw.lxc). + err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error { + return project.AllowInstanceUpdate(tx, inst.Project().Name, inst.Name(), targetInstInfo.Writable(), inst.LocalConfig()) + }) + if err != nil { + return err + } + // Handle storage pool override. if req.Pool != "" { err := s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error { diff --git a/cmd/incusd/instances_post.go b/cmd/incusd/instances_post.go index 7f12eeca3e..df6e3c2dff 100644 --- a/cmd/incusd/instances_post.go +++ b/cmd/incusd/instances_post.go @@ -709,6 +709,15 @@ func createFromCopy(ctx context.Context, s *state.State, r *http.Request, projec req.Devices[key] = value } + // Re-check project restrictions against the fully merged config, as the source + // instance's config and devices (including restricted keys) are only merged in above. + err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error { + return project.AllowInstanceCreation(tx, targetProject, *req) + }) + if err != nil { + return response.SmartError(err) + } + if req.Stateful { sourceName, _, _ := api.GetParentAndSnapshotName(source.Name()) if sourceName != req.Name { @@ -888,6 +897,19 @@ func createFromBackup(s *state.State, r *http.Request, projectName string, data bInfo.Name = instanceName } + // Validate the instance and snapshot names to avoid path traversal when used as path segments. + err = instance.ValidName(bInfo.Name, false) + if err != nil { + return response.BadRequest(err) + } + + for _, snapName := range bInfo.Snapshots { + err = instance.ValidName(bInfo.Name+internalInstance.SnapshotDelimiter+snapName, true) + if err != nil { + return response.BadRequest(err) + } + } + // Override config. configMap := map[string]string{} if config != "" { diff --git a/cmd/incusd/storage_volumes.go b/cmd/incusd/storage_volumes.go index 049c3ad4ed..a0b7c8c105 100644 --- a/cmd/incusd/storage_volumes.go +++ b/cmd/incusd/storage_volumes.go @@ -2770,6 +2770,12 @@ func createStoragePoolVolumeFromISO(s *state.State, r *http.Request, requestProj return response.BadRequest(errors.New("Missing volume name")) } + // Validate the volume name to avoid path traversal when used as a path segment. + err := validate.IsAPIName(volName, false) + if err != nil { + return response.BadRequest(fmt.Errorf("Invalid volume name: %w", err)) + } + // Create isos directory if needed. if !util.PathExists(internalUtil.VarPath("isos")) { err := os.MkdirAll(internalUtil.VarPath("isos"), 0o644) @@ -2937,6 +2943,19 @@ func createStoragePoolVolumeFromBackup(s *state.State, r *http.Request, requestP bInfo.Name = volName } + // Validate the volume and snapshot names to avoid path traversal when used as path segments. + err = validate.IsAPIName(bInfo.Name, false) + if err != nil { + return response.BadRequest(fmt.Errorf("Invalid volume name: %w", err)) + } + + for _, snapName := range bInfo.Snapshots { + err = validate.IsAPIName(snapName, false) + if err != nil { + return response.BadRequest(fmt.Errorf("Invalid snapshot name: %w", err)) + } + } + logger.Debug("Backup file info loaded", logger.Ctx{ "type": bInfo.Type, "name": bInfo.Name, diff --git a/internal/instance/config.go b/internal/instance/config.go index 17fee0b867..16b188a50c 100644 --- a/internal/instance/config.go +++ b/internal/instance/config.go @@ -18,6 +18,24 @@ func IsUserConfig(key string) bool { return strings.HasPrefix(key, "user.") } +// isNvidiaConfigValue rejects line breaks that would allow injecting arbitrary directives into the generated LXC configuration. +func isNvidiaConfigValue(value string) error { + if strings.ContainsAny(value, "\r\n") { + return errors.New("NVIDIA configuration values cannot contain line breaks") + } + + return nil +} + +// isResolvConfValue rejects line breaks that would allow injecting arbitrary lines into the generated resolv.conf. +func isResolvConfValue(value string) error { + if strings.ContainsAny(value, "\r\n") { + return errors.New("Value cannot contain line breaks") + } + + return nil +} + // ConfigVolatilePrefix indicates the prefix used for volatile config keys. const ConfigVolatilePrefix = "volatile." @@ -755,7 +773,7 @@ var InstanceConfigKeysContainer = map[string]func(value string) error{ // liveupdate: no // condition: container // shortdesc: What driver capabilities the instance needs - "nvidia.driver.capabilities": validate.IsAny, + "nvidia.driver.capabilities": isNvidiaConfigValue, // gendoc:generate(entity=instance, group=nvidia, key=nvidia.require.cuda) // The specified version expression is used to set `libnvidia-container NVIDIA_REQUIRE_CUDA`. @@ -764,7 +782,7 @@ var InstanceConfigKeysContainer = map[string]func(value string) error{ // liveupdate: no // condition: container // shortdesc: Required CUDA version - "nvidia.require.cuda": validate.IsAny, + "nvidia.require.cuda": isNvidiaConfigValue, // gendoc:generate(entity=instance, group=nvidia, key=nvidia.require.driver) // The specified version expression is used to set `libnvidia-container NVIDIA_REQUIRE_DRIVER`. @@ -773,7 +791,7 @@ var InstanceConfigKeysContainer = map[string]func(value string) error{ // liveupdate: no // condition: container // shortdesc: Required driver version - "nvidia.require.driver": validate.IsAny, + "nvidia.require.driver": isNvidiaConfigValue, // gendoc:generate(entity=instance, group=oci, key=oci.entrypoint) // Override the entry point of an OCI container. @@ -827,7 +845,7 @@ var InstanceConfigKeysContainer = map[string]func(value string) error{ // liveupdate: no // condition: OCI container // shortdesc: DNS domain - "oci.dns.domain": validate.IsAny, + "oci.dns.domain": validate.Optional(isResolvConfValue), // gendoc:generate(entity=instance, group=oci, key=oci.dns.search) // Comma-separated list of search domains for the initial `resolv.conf`. @@ -836,7 +854,7 @@ var InstanceConfigKeysContainer = map[string]func(value string) error{ // liveupdate: no // condition: OCI container // shortdesc: DNS search domains - "oci.dns.search": validate.IsAny, + "oci.dns.search": validate.Optional(validate.IsListOf(isResolvConfValue)), // Caller is responsible for full validation of any raw.* value. diff --git a/internal/server/instance/drivers/driver_lxc.go b/internal/server/instance/drivers/driver_lxc.go index 2d55e2a5d8..5e8ac77954 100644 --- a/internal/server/instance/drivers/driver_lxc.go +++ b/internal/server/instance/drivers/driver_lxc.go @@ -1143,7 +1143,7 @@ func (d *lxc) initLXC(config bool) (*liblxc.Container, error) { } nvidiaRequireCuda := d.expandedConfig["nvidia.require.cuda"] - if nvidiaRequireCuda == "" { + if nvidiaRequireCuda != "" { err = lxcSetConfigItem(cc, "lxc.environment", fmt.Sprintf("\"NVIDIA_REQUIRE_CUDA=%s\"", nvidiaRequireCuda)) if err != nil { return nil, err @@ -1151,7 +1151,7 @@ func (d *lxc) initLXC(config bool) (*liblxc.Container, error) { } nvidiaRequireDriver := d.expandedConfig["nvidia.require.driver"] - if nvidiaRequireDriver == "" { + if nvidiaRequireDriver != "" { err = lxcSetConfigItem(cc, "lxc.environment", fmt.Sprintf("\"NVIDIA_REQUIRE_DRIVER=%s\"", nvidiaRequireDriver)) if err != nil { return nil, err @@ -2617,17 +2617,25 @@ func (d *lxc) startCommon() (string, []func() error, error) { } // Configure network handling. - err = os.MkdirAll(filepath.Join(d.Path(), "network"), 0o711) + // Confine all writes to the instance directory to avoid following image-planted symlinks. + instRoot, err := os.OpenRoot(d.Path()) if err != nil { return "", nil, err } + defer logger.WarnOnError(instRoot.Close, "Failed to close instance root") + + err = instRoot.Mkdir("network", 0o711) + if err != nil && !errors.Is(err, fs.ErrExist) { + return "", nil, err + } + err = os.MkdirAll(filepath.Join(d.RootfsPath(), "etc"), 0o755) if err != nil && !os.IsExist(err) { return "", nil, err } - err = os.WriteFile(filepath.Join(d.Path(), "network", "hosts"), fmt.Appendf(nil, `127.0.0.1 localhost + err = instRoot.WriteFile("network/hosts", fmt.Appendf(nil, `127.0.0.1 localhost 127.0.1.1 %s ::1 localhost ip6-localhost ip6-loopback @@ -2645,7 +2653,7 @@ ff02::2 ip6-allrouters return "", nil, err } - err = os.WriteFile(filepath.Join(d.Path(), "network", "hostname"), fmt.Appendf(nil, "%s\n", d.name), 0o644) + err = instRoot.WriteFile("network/hostname", fmt.Appendf(nil, "%s\n", d.name), 0o644) if err != nil { return "", nil, err } @@ -2669,7 +2677,7 @@ ff02::2 ip6-allrouters fmt.Fprintf(&resolvConf, "domain %s\n", d.expandedConfig["oci.dns.domain"]) } - err = os.WriteFile(filepath.Join(d.Path(), "network", "resolv.conf"), []byte(resolvConf.String()), 0o644) + err = instRoot.WriteFile("network/resolv.conf", []byte(resolvConf.String()), 0o644) if err != nil { return "", nil, err } @@ -2699,7 +2707,7 @@ ff02::2 ip6-allrouters return "", nil, err } - err = os.WriteFile(filepath.Join(d.Path(), "network", "interfaces.json"), ifacesData, 0o644) + err = instRoot.WriteFile("network/interfaces.json", ifacesData, 0o644) if err != nil { return "", nil, err } diff --git a/internal/server/instance/drivers/driver_qemu.go b/internal/server/instance/drivers/driver_qemu.go index 42815df197..319c1c7d72 100644 --- a/internal/server/instance/drivers/driver_qemu.go +++ b/internal/server/instance/drivers/driver_qemu.go @@ -3777,6 +3777,14 @@ func (d *qemu) templateApplyNow(trigger instance.TemplateTrigger, path string) e instanceMeta["ephemeral"] = "false" } + // Open the output directory as an os.Root so all template writes stay confined to it. + outputRoot, err := os.OpenRoot(path) + if err != nil { + return fmt.Errorf("Failed to open template output path: %w", err) + } + + defer logger.WarnOnError(outputRoot.Close, "Failed to close template output path") + // Go through the templates. for tplPath, tpl := range metadata.Templates { err = func(tplPath string, tpl *api.ImageMetadataTemplate) error { @@ -3789,8 +3797,31 @@ func (d *qemu) templateApplyNow(trigger instance.TemplateTrigger, path string) e return nil } + // Perform some early security checks on the template itself. + if filepath.Base(tpl.Template) != tpl.Template { + return errors.New("Template path is attempting to read outside of template directory") + } + + tplDirStat, err := os.Lstat(d.TemplatesPath()) + if err != nil { + return fmt.Errorf("Couldn't access template directory: %w", err) + } + + if !tplDirStat.IsDir() { + return errors.New("Template directory isn't a regular directory") + } + + tplFileStat, err := os.Lstat(filepath.Join(d.TemplatesPath(), tpl.Template)) + if err != nil { + return fmt.Errorf("Couldn't access template file: %w", err) + } + + if tplFileStat.Mode()&os.ModeSymlink == os.ModeSymlink { + return errors.New("Template file is a symlink") + } + // Create the file itself. - w, err = os.Create(filepath.Join(path, fmt.Sprintf("%s.out", tpl.Template))) + w, err = outputRoot.Create(fmt.Sprintf("%s.out", tpl.Template)) if err != nil { return err } diff --git a/internal/server/project/permissions.go b/internal/server/project/permissions.go index 4bf91dedaf..038acc4b99 100644 --- a/internal/server/project/permissions.go +++ b/internal/server/project/permissions.go @@ -318,6 +318,19 @@ func AllowVolumeCreation(tx *db.ClusterTx, projectName string, poolName string, return errors.New("Restricted projects aren't allowed to use pull mode migration") } + // Restricted projects can't override low-level volume options that are passed to + // filesystem tooling running as root; they may only use the pool's configured default. + if util.IsTrue(info.Project.Config["restricted"]) { + _, pool, _, err := tx.GetStoragePool(context.Background(), poolName) + if err != nil { + return err + } + + if req.Config["block.create_options"] != "" && req.Config["block.create_options"] != pool.Config["volume.block.create_options"] { + return errors.New(`Storage volume option "block.create_options" cannot be set in a restricted project`) + } + } + // Add the volume being created. info.Volumes = append(info.Volumes, db.StorageVolumeArgs{ Name: req.Name, @@ -585,6 +598,7 @@ func checkRestrictions(project api.Project, instances []api.Instance, profiles [ allowContainerLowLevel := false allowVMLowLevel := false blockVMNesting := false + requireIsolated := false var allowedIDMapHostUIDs, allowedIDMapHostGIDs []idmap.Entry for i := range allRestrictions { @@ -633,6 +647,8 @@ func checkRestrictions(project api.Project, instances []api.Instance, profiles [ return nil } + requireIsolated = restrictionValue == "isolated" + containerConfigChecks["security.idmap.isolated"] = func(instanceValue string) error { if restrictionValue == "isolated" && util.IsFalseOrEmpty(instanceValue) { return errors.New("Non-isolated containers are forbidden") @@ -827,6 +843,11 @@ func checkRestrictions(project api.Project, instances []api.Instance, profiles [ return fmt.Errorf(`Virtual machine nesting is forbidden on %s %q of project %q ("security.nesting" must be set to "false")`, entityTypeLabel, entityName, project.Name) } + // Non-isolation is the default, so when isolation is required, the container must explicitly enable it. + if requireIsolated && instType == instancetype.Container && util.IsFalseOrEmpty(config["security.idmap.isolated"]) { + return fmt.Errorf(`Non-isolated containers are forbidden on %s %q of project %q ("security.idmap.isolated" must be set to "true")`, entityTypeLabel, entityName, project.Name) + } + for key, value := range config { if ((isContainerOrProfile && !allowContainerLowLevel) || (isVMOrProfile && !allowVMLowLevel)) && key == "raw.idmap" { // If the low-level raw.idmap is used check whether the raw.idmap host IDs diff --git a/internal/server/storage/backend.go b/internal/server/storage/backend.go index 677ba023d0..1efc0b2805 100644 --- a/internal/server/storage/backend.go +++ b/internal/server/storage/backend.go @@ -7216,9 +7216,17 @@ func (b *backend) UpdateInstanceBackupFile(inst instance.Instance, snapshots boo // Update pool information in the backup.yaml file. err = vol.MountTask(func(mountPath string, op *operations.Operation) error { + // Confine the write to the instance directory to avoid following image-planted symlinks. + root, err := os.OpenRoot(inst.Path()) + if err != nil { + return err + } + + defer func() { _ = root.Close() }() + // Write the YAML path := filepath.Join(inst.Path(), "backup.yaml") - f, err := os.Create(path) + f, err := root.OpenFile("backup.yaml", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o400) if err != nil { return fmt.Errorf("Failed to create file %q: %w", path, err) }