From 041269ff2670e235faa49e3c973de657ace3f28d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Fri, 24 Jul 2026 11:37:25 -0400 Subject: [PATCH 01/17] incusd/project: Restrict volume creation options in restricted projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user in a restricted project could set block.create_options to inject arguments into the filesystem creation command run as root. Restrict such projects to the pool's configured default for that option. This addresses CVE-2026-62867 Signed-off-by: Stéphane Graber --- internal/server/project/permissions.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/server/project/permissions.go b/internal/server/project/permissions.go index 4bf91dedaf..900f3c974f 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, From 4d64535046e63c86ded3338ac58329db88d1492d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Fri, 24 Jul 2026 11:39:16 -0400 Subject: [PATCH 02/17] internal/instance: Prevent line breaks in NVIDIA config values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This addresses GHSA-m3j6-p3v3-qmjv (CVE pending) Signed-off-by: Stéphane Graber --- internal/instance/config.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/instance/config.go b/internal/instance/config.go index 17fee0b867..c1756853c6 100644 --- a/internal/instance/config.go +++ b/internal/instance/config.go @@ -18,6 +18,15 @@ 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 +} + // ConfigVolatilePrefix indicates the prefix used for volatile config keys. const ConfigVolatilePrefix = "volatile." @@ -755,7 +764,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 +773,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 +782,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. From e46720cb6dc6223f24d804e1c085257caab17b53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Fri, 24 Jul 2026 11:42:32 -0400 Subject: [PATCH 03/17] incusd/instance: Confine OCI network writes to instance root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also rejects line breaks in the oci.dns.domain and oci.dns.search values which are written to the generated resolv.conf. This addresses GHSA-7fj9-65v4-rp7h (CVE pending) Signed-off-by: Stéphane Graber --- internal/instance/config.go | 13 +++++++++++-- internal/server/instance/drivers/driver_lxc.go | 18 +++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/internal/instance/config.go b/internal/instance/config.go index c1756853c6..16b188a50c 100644 --- a/internal/instance/config.go +++ b/internal/instance/config.go @@ -27,6 +27,15 @@ func isNvidiaConfigValue(value string) error { 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." @@ -836,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`. @@ -845,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..8b26df30d7 100644 --- a/internal/server/instance/drivers/driver_lxc.go +++ b/internal/server/instance/drivers/driver_lxc.go @@ -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 } From 1ff313f55454a1d4fb83557306c0fd6c2a1927a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Fri, 24 Jul 2026 11:43:20 -0400 Subject: [PATCH 04/17] incusd/storage: Confine backup.yaml write to instance root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This addresses CVE-2026-63125 Signed-off-by: Stéphane Graber --- internal/server/storage/backend.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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) } From 443e845023ae62befe916980f05ffd8933652816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Fri, 24 Jul 2026 11:44:35 -0400 Subject: [PATCH 05/17] incusd/instance: Confine metadata.yaml access to instance root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This addresses CVE-2026-63343 Signed-off-by: Stéphane Graber --- cmd/incusd/instance_metadata.go | 53 ++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 17 deletions(-) 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) } From 436041b4930f8515b73e060419aceaeefe0e760d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Fri, 24 Jul 2026 11:45:40 -0400 Subject: [PATCH 06/17] incusd/instance/qemu: Confine template access to instance root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The template traversal guard from CVE-2026-48752 was only applied to the LXC driver. Apply the same checks to the QEMU driver. This addresses GHSA-4qxq-p5hm-3q3p (CVE pending) Signed-off-by: Stéphane Graber --- .../server/instance/drivers/driver_qemu.go | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal/server/instance/drivers/driver_qemu.go b/internal/server/instance/drivers/driver_qemu.go index 42815df197..3e3a53e1ff 100644 --- a/internal/server/instance/drivers/driver_qemu.go +++ b/internal/server/instance/drivers/driver_qemu.go @@ -3789,6 +3789,29 @@ 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))) if err != nil { From 42be307a1452ee54f8822e9bcf45c70a6af0e90c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Fri, 24 Jul 2026 11:47:49 -0400 Subject: [PATCH 07/17] incusd/images: Validate image fingerprint for all protocols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fingerprint validation added for CVE-2026-48769 was only applied to the direct protocol. Validate it for all protocols and re-check after it is taken from the remote server's response. This addresses GHSA-p2v3-6wvc-cv3p (CVE pending) Signed-off-by: Stéphane Graber --- cmd/incusd/daemon_images.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) 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 { From e2547b834f1cead6c57a3ac034fdf9365c8f7af7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Fri, 24 Jul 2026 11:49:01 -0400 Subject: [PATCH 08/17] incusd/storage: Validate volume name on ISO and backup import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This addresses GHSA-67qw-68v3-36h6 (CVE pending) Signed-off-by: Stéphane Graber --- cmd/incusd/storage_volumes.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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, From c203fcf2559d16e2168eab6f1776c449f70d1ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Fri, 24 Jul 2026 11:50:44 -0400 Subject: [PATCH 09/17] incusd/instances: Validate instance name on backup import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This addresses GHSA-26gp-p5fw-3r2h (CVE pending) Signed-off-by: Stéphane Graber --- cmd/incusd/instances_post.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cmd/incusd/instances_post.go b/cmd/incusd/instances_post.go index 7f12eeca3e..24f9fad546 100644 --- a/cmd/incusd/instances_post.go +++ b/cmd/incusd/instances_post.go @@ -888,6 +888,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 != "" { From 7cec16a23cbc6dd3df8228cf20b59cecf1e4138e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Fri, 24 Jul 2026 11:52:19 -0400 Subject: [PATCH 10/17] incusd/instances: Re-check restrictions after copy config merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source instance's config is merged into the request only after the initial project restriction check, letting a cross-project copy carry restricted keys into the target project. Re-check the merged config. This addresses CVE-2026-62941 Signed-off-by: Stéphane Graber --- cmd/incusd/instances_post.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cmd/incusd/instances_post.go b/cmd/incusd/instances_post.go index 24f9fad546..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 { From f1c75c6c48b5fe7d7c0d60ee5009522e7ac3a069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Fri, 24 Jul 2026 11:54:08 -0400 Subject: [PATCH 11/17] incusd/instance: Enforce project restrictions on migration overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration handler applied user-supplied config, device and profile overrides without any project restriction check, letting a restricted project set keys like security.privileged or raw.lxc. This addresses CVE-2026-62940 Signed-off-by: Stéphane Graber --- cmd/incusd/instance_post.go | 9 +++++++++ 1 file changed, 9 insertions(+) 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 { From 8066e7707ccf423b6c41ad12976a0998e979e8f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Fri, 24 Jul 2026 11:56:00 -0400 Subject: [PATCH 12/17] incusd/project: Enforce isolated restriction when idmap key omitted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restricted.containers.privilege=isolated only rejected an explicit security.idmap.isolated=false, but the key defaults to non-isolated when omitted. Require containers to explicitly enable isolation. This addresses CVE-2026-62313 Signed-off-by: Stéphane Graber --- internal/server/project/permissions.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/server/project/permissions.go b/internal/server/project/permissions.go index 900f3c974f..038acc4b99 100644 --- a/internal/server/project/permissions.go +++ b/internal/server/project/permissions.go @@ -598,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 { @@ -646,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") @@ -840,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 From 620c836181dd011672541835803f33712aa648fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Fri, 24 Jul 2026 15:40:41 -0400 Subject: [PATCH 13/17] incusd: Expand network address set project for authorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project expansion used for authorization had no branch for network address sets, so their access checks ran against the requested project rather than the effective one, letting a restricted project act on the default project's address sets. This addresses GHSA-6v6x-387m-rj4w (CVE pending) Signed-off-by: Stéphane Graber --- cmd/incusd/daemon.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/incusd/daemon.go b/cmd/incusd/daemon.go index 62c15cf7f2..09c3c258e0 100644 --- a/cmd/incusd/daemon.go +++ b/cmd/incusd/daemon.go @@ -366,7 +366,7 @@ func allowPermission(objectType auth.ObjectType, entitlement auth.Entitlement, m projectName = 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) { + } else if slices.Contains([]auth.ObjectType{auth.ObjectTypeNetwork, auth.ObjectTypeNetworkACL, auth.ObjectTypeNetworkAddressSet}, objectType) { projectName = project.NetworkProjectFromRecord(p) } From 23066254f5dcc16b9e9022e938142ba512098858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Fri, 24 Jul 2026 12:20:05 -0400 Subject: [PATCH 14/17] incusd/instance: Fix NVIDIA require.cuda and require.driver handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The condition was inverted, so a configured value was silently dropped while an empty value wrote an empty environment variable. Only write the variable when a value is set. Signed-off-by: Stéphane Graber --- internal/server/instance/drivers/driver_lxc.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/server/instance/drivers/driver_lxc.go b/internal/server/instance/drivers/driver_lxc.go index 8b26df30d7..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 From b76f970a63b12a0e711af2dc529b558aa7865555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Fri, 24 Jul 2026 15:47:06 -0400 Subject: [PATCH 15/17] incusd/instance: Confine exec-output access to its directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serve, list and delete exec output files through an os.OpenRoot handle so a symlink in the exec-output directory can't be followed outside of it. Signed-off-by: Stéphane Graber --- cmd/incusd/instance_logs.go | 54 +++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 5 deletions(-) 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) } From 8e2eab60d8f62f7aaeb252b4b68537b808c28d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Fri, 24 Jul 2026 15:48:47 -0400 Subject: [PATCH 16/17] incusd: Fail closed on unknown authorization project expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project expansion used for authorization silently returned the requested project for any object type without an explicit branch, so a new project-scoped API missing from the switch would bypass project confinement. Handle every object type explicitly and error out otherwise. Signed-off-by: Stéphane Graber --- cmd/incusd/daemon.go | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/cmd/incusd/daemon.go b/cmd/incusd/daemon.go index 09c3c258e0..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) + return project.ImageProjectFromRecord(p) } else if slices.Contains([]auth.ObjectType{auth.ObjectTypeNetwork, auth.ObjectTypeNetworkACL, auth.ObjectTypeNetworkAddressSet}, objectType) { - projectName = project.NetworkProjectFromRecord(p) + 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 From 154089f398534cbcbc2ba62ced1e9ce4245be059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Graber?= Date: Tue, 28 Jul 2026 12:31:15 -0400 Subject: [PATCH 17/17] incusd/instance/qemu: Use os.Root for template output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the LXC driver, the existing checks already prevent escaping. Signed-off-by: Stéphane Graber --- internal/server/instance/drivers/driver_qemu.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/server/instance/drivers/driver_qemu.go b/internal/server/instance/drivers/driver_qemu.go index 3e3a53e1ff..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 { @@ -3813,7 +3821,7 @@ func (d *qemu) templateApplyNow(trigger instance.TemplateTrigger, path string) e } // 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 }