Version in base suite: 6.0.4-2+deb13u10 Base version: incus_6.0.4-2+deb13u10 Target version: incus_6.0.4-2+deb13u11 Base file: /srv/ftp-master.debian.org/ftp/pool/main/i/incus/incus_6.0.4-2+deb13u10.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/i/incus/incus_6.0.4-2+deb13u11.dsc changelog | 15 patches/149-cherry-pick-rsync-xattrs-transfer.patch | 56 +++ patches/150-GHSA-4cph-ccqv-hm3c.patch | 159 ++++++++ patches/151-GHSA-579w-c4rw-c8q3.patch | 120 ++++++ patches/152-GHSA-x8gj-2q73-qr6j.patch | 43 ++ patches/153-GHSA-jh4v-j34r-2mgh.patch | 39 ++ patches/154-GHSA-mfwv-x733-9446.patch | 259 ++++++++++++++ patches/155-GHSA-h85r-gjgx-g2rv-GHSA-27q7-qwhm-c34p.patch | 85 ++++ patches/series | 7 9 files changed, 783 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp9wx657um/incus_6.0.4-2+deb13u10.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp9wx657um/incus_6.0.4-2+deb13u11.dsc: no acceptable signature found diff -Nru incus-6.0.4/debian/changelog incus-6.0.4/debian/changelog --- incus-6.0.4/debian/changelog 2026-09-02 16:39:29.000000000 +0000 +++ incus-6.0.4/debian/changelog 2026-09-25 14:31:18.000000000 +0000 @@ -1,3 +1,18 @@ +incus (6.0.4-2+deb13u11) trixie-security; urgency=high + + * Cherry-pick fix for only transferring xattrs settable without + CAP_SYS_ADMIN + * Cherry-pick fixes for the following security issues: + - GHSA-4cph-ccqv-hm3c + - GHSA-579w-c4rw-c8q3 + - GHSA-x8gj-2q73-qr6j + - GHSA-jh4v-j34r-2mgh + - GHSA-mfwv-x733-9446 + - GHSA-h85r-gjgx-g2rv + - GHSA-27q7-qwhm-c34p + + -- Mathias Gibbens Fri, 25 Sep 2026 14:31:18 +0000 + incus (6.0.4-2+deb13u10) trixie; urgency=medium * Cherry-pick fixes for the following security issues diff -Nru incus-6.0.4/debian/patches/149-cherry-pick-rsync-xattrs-transfer.patch incus-6.0.4/debian/patches/149-cherry-pick-rsync-xattrs-transfer.patch --- incus-6.0.4/debian/patches/149-cherry-pick-rsync-xattrs-transfer.patch 1970-01-01 00:00:00.000000000 +0000 +++ incus-6.0.4/debian/patches/149-cherry-pick-rsync-xattrs-transfer.patch 2026-09-25 14:26:49.000000000 +0000 @@ -0,0 +1,56 @@ +From 747213df51b3015cf0eb712dde9f112df12dddfb Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?St=C3=A9phane=20Graber?= +Date: Mon, 7 Sep 2026 11:53:16 -0400 +Subject: [PATCH] incusd/rsync: Only transfer xattrs settable without + CAP_SYS_ADMIN +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Restrict xattrs to user.* and security.capability on the receiver so +rsync doesn't fail on trusted.* or other security.* xattrs, which the +AppArmor profile rightly prevents it from setting. + +Closes #3963 + +Signed-off-by: Stéphane Graber +Rebased-by: Tim Small +--- + internal/rsync/rsync.go | 14 ++++++++++++-- + 1 file changed, 12 insertions(+), 2 deletions(-) + +--- a/internal/rsync/rsync.go ++++ b/internal/rsync/rsync.go +@@ -21,6 +21,14 @@ + "github.com/lxc/incus/v6/shared/subprocess" + ) + ++// xattrFilterArgs restricts rsync to xattrs that can be set without CAP_SYS_ADMIN. ++// It is enforced by the receiver regardless of what the sender provides. ++var xattrFilterArgs = []string{ ++ "--filter=+x user.*", ++ "--filter=+x security.capability", ++ "--filter=-x *", ++} ++ + // Debug controls additional debugging in rsync output. + var Debug bool + +@@ -88,7 +96,7 @@ + if xattrs { + args = append(args, "--xattrs") + if AtLeast("3.1.3") { +- args = append(args, "--filter=-x security.selinux") ++ args = append(args, xattrFilterArgs...) + } + } + +@@ -424,7 +432,7 @@ + if slices.Contains(features, "xattrs") { + args = append(args, "--xattrs") + if AtLeast("3.1.3") { +- args = append(args, "--filter=-x security.selinux") ++ args = append(args, xattrFilterArgs...) + } + } + diff -Nru incus-6.0.4/debian/patches/150-GHSA-4cph-ccqv-hm3c.patch incus-6.0.4/debian/patches/150-GHSA-4cph-ccqv-hm3c.patch --- incus-6.0.4/debian/patches/150-GHSA-4cph-ccqv-hm3c.patch 1970-01-01 00:00:00.000000000 +0000 +++ incus-6.0.4/debian/patches/150-GHSA-4cph-ccqv-hm3c.patch 2026-09-25 14:26:49.000000000 +0000 @@ -0,0 +1,159 @@ +From 8bfc0852b763b39b8c5324e6a09ab4e8af7cd92e Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?St=C3=A9phane=20Graber?= +Date: Tue, 15 Sep 2026 22:25:57 -0400 +Subject: [PATCH] incusd/project: Restrict volume options on update and copy +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +The block.create_options restriction only covered direct volume creation. +Apply it to volume updates and to the effective config of volumes created +or refreshed from a copy, which fall back to the source volume's config. + +This addresses GHSA-4cph-ccqv-hm3c (CVE pending) + +Signed-off-by: Stéphane Graber +Rebased-by: Mathias Gibbens +--- + cmd/incusd/storage_volumes.go | 2 +- + internal/server/project/permissions.go | 61 +++++++++++++++++++++----- + internal/server/storage/backend.go | 16 +++++++ + 3 files changed, 66 insertions(+), 13 deletions(-) + +diff --git a/cmd/incusd/storage_volumes.go b/cmd/incusd/storage_volumes.go +index d62a047be8..b440d81871 100644 +--- a/cmd/incusd/storage_volumes.go ++++ b/cmd/incusd/storage_volumes.go +@@ -1914,7 +1914,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) +diff --git a/internal/server/project/permissions.go b/internal/server/project/permissions.go +index fd38d375b5..cdd07efd5d 100644 +--- a/internal/server/project/permissions.go ++++ b/internal/server/project/permissions.go +@@ -268,17 +268,9 @@ func AllowVolumeCreation(tx *db.ClusterTx, projectName string, poolName string, + return nil + } + +- // 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 fmt.Errorf(`Storage volume option "block.create_options" cannot be set in a restricted project`) +- } ++ err = checkRestrictedVolumeConfig(tx, info, poolName, req.Config, nil) ++ if err != nil { ++ return err + } + + // Add the volume being created. +@@ -1001,9 +993,49 @@ func AllowInstanceUpdate(tx *db.ClusterTx, projectName, instanceName string, req + return nil + } + ++// checkRestrictedVolumeConfig returns an error if a restricted project attempts to set ++// low-level volume options that are passed to filesystem tooling running as root. ++// Such projects may only use the pool's configured default or keep the volume's current value. ++func checkRestrictedVolumeConfig(tx *db.ClusterTx, info *projectInfo, poolName string, config map[string]string, currentConfig map[string]string) error { ++ if !util.IsTrue(info.Project.Config["restricted"]) { ++ return nil ++ } ++ ++ value := config["block.create_options"] ++ if value == "" || value == currentConfig["block.create_options"] { ++ return nil ++ } ++ ++ _, pool, _, err := tx.GetStoragePool(context.Background(), poolName) ++ if err != nil { ++ return err ++ } ++ ++ if value != pool.Config["volume.block.create_options"] { ++ return fmt.Errorf(`Storage volume option "block.create_options" cannot be set in a restricted project`) ++ } ++ ++ return nil ++} ++ ++// AllowVolumeConfig returns an error if any project-specific restriction is ++// violated by the effective config of a custom volume being created. ++func AllowVolumeConfig(tx *db.ClusterTx, projectName string, poolName string, config map[string]string) error { ++ info, err := fetchProject(tx, projectName, true) ++ if err != nil { ++ return err ++ } ++ ++ if info == nil { ++ return nil ++ } ++ ++ return checkRestrictedVolumeConfig(tx, info, poolName, config, nil) ++} ++ + // AllowVolumeUpdate returns an error if any project-specific limit or + // restriction is violated when updating an existing custom volume. +-func AllowVolumeUpdate(tx *db.ClusterTx, projectName, volumeName string, req api.StorageVolumePut, currentConfig map[string]string) error { ++func AllowVolumeUpdate(tx *db.ClusterTx, projectName string, poolName string, volumeName string, req api.StorageVolumePut, currentConfig map[string]string) error { + info, err := fetchProject(tx, projectName, true) + if err != nil { + return err +@@ -1013,6 +1045,11 @@ func AllowVolumeUpdate(tx *db.ClusterTx, projectName, volumeName string, req api + return nil + } + ++ err = checkRestrictedVolumeConfig(tx, info, poolName, req.Config, currentConfig) ++ if err != nil { ++ return err ++ } ++ + // If "limits.disk" is not set, there's nothing to do. + if info.Project.Config["limits.disk"] == "" { + return nil +diff --git a/internal/server/storage/backend.go b/internal/server/storage/backend.go +index 50b131f1e5..041310abf8 100644 +--- a/internal/server/storage/backend.go ++++ b/internal/server/storage/backend.go +@@ -1285,6 +1285,14 @@ func (b *backend) RefreshCustomVolume(projectName string, srcProjectName string, + config = srcConfig.Volume.Config + } + ++ // Check project restrictions against the effective config. ++ err = b.state.DB.Cluster.Transaction(b.state.ShutdownCtx, func(ctx context.Context, tx *db.ClusterTx) error { ++ return project.AllowVolumeConfig(tx, projectName, b.name, config) ++ }) ++ if err != nil { ++ return err ++ } ++ + // Use the source volume's description if not supplied. + if desc == "" { + desc = srcConfig.Volume.Description +@@ -4719,6 +4727,14 @@ func (b *backend) CreateCustomVolumeFromCopy(projectName string, srcProjectName + config = srcConfig.Volume.Config + } + ++ // Check project restrictions against the effective config. ++ err = b.state.DB.Cluster.Transaction(b.state.ShutdownCtx, func(ctx context.Context, tx *db.ClusterTx) error { ++ return project.AllowVolumeConfig(tx, projectName, b.name, config) ++ }) ++ if err != nil { ++ return err ++ } ++ + // Use the source volume's description if not supplied. + if desc == "" { + desc = srcConfig.Volume.Description +-- +2.47.3 diff -Nru incus-6.0.4/debian/patches/151-GHSA-579w-c4rw-c8q3.patch incus-6.0.4/debian/patches/151-GHSA-579w-c4rw-c8q3.patch --- incus-6.0.4/debian/patches/151-GHSA-579w-c4rw-c8q3.patch 1970-01-01 00:00:00.000000000 +0000 +++ incus-6.0.4/debian/patches/151-GHSA-579w-c4rw-c8q3.patch 2026-09-25 14:28:45.000000000 +0000 @@ -0,0 +1,120 @@ +From 606fdfed4ea1d9a6be5d6c8a5dd38afabae6dfee Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?St=C3=A9phane=20Graber?= +Date: Fri, 28 Aug 2026 20:59:21 -0400 +Subject: [PATCH] incusd: Don't follow symlinks when receiving migration data +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +The migration source controls the rsync and btrfs streams and can plant +a symlink, such as rootfs pointing at /, then write through it while the +transfer is still running, before the post-transfer symlink check. +Bind-mount the receive path with nosymfollow for the duration of the +receive and refuse a block volume file that was replaced by a symlink. + +This addresses GHSA-579w-c4rw-c8q3 (CVE pending) + +Signed-off-by: Stéphane Graber +Rebased-by: Mathias Gibbens +--- + internal/linux/filesystem.go | 29 +++++++++++++++++++ + internal/rsync/rsync.go | 8 +++++ + .../storage/drivers/driver_btrfs_utils.go | 3 +- + .../server/storage/drivers/generic_vfs.go | 12 ++++++++ + 4 files changed, 51 insertions(+), 1 deletion(-) + +diff --git a/internal/linux/filesystem.go b/internal/linux/filesystem.go +index 5fe2996e13..cfc139bfd4 100644 +--- a/internal/linux/filesystem.go ++++ b/internal/linux/filesystem.go +@@ -324,3 +324,32 @@ func GetMountinfo(path string) ([]string, error) { + + return nil, fmt.Errorf("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 ++} +diff --git a/internal/rsync/rsync.go b/internal/rsync/rsync.go +index 0a6c0bea5f..766a47139b 100644 +--- a/internal/rsync/rsync.go ++++ b/internal/rsync/rsync.go +@@ -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 func() { unmount() }() ++ + cmd := exec.Command("rsync", args...) + + // Call the wrapper if defined. +diff --git a/internal/server/storage/drivers/driver_btrfs_utils.go b/internal/server/storage/drivers/driver_btrfs_utils.go +index 56cf1f584f..e85a9dff60 100644 +--- a/internal/server/storage/drivers/driver_btrfs_utils.go ++++ b/internal/server/storage/drivers/driver_btrfs_utils.go +@@ -622,7 +622,8 @@ func (d *btrfs) receiveSubVolume(r io.Reader, receivePath string, tracker *iopro + } + } + +- err = subprocess.RunCommandWithFds(context.TODO(), stdin, nil, "btrfs", "receive", "-e", receivePath) ++ // Confine the receiver to the target so the stream can't redirect writes outside of it. ++ err = subprocess.RunCommandWithFds(context.TODO(), stdin, nil, "btrfs", "receive", "--chroot", "-e", receivePath) + if err != nil { + return "", err + } +diff --git a/internal/server/storage/drivers/generic_vfs.go b/internal/server/storage/drivers/generic_vfs.go +index dffc3157f7..13e69a65ca 100644 +--- a/internal/server/storage/drivers/generic_vfs.go ++++ b/internal/server/storage/drivers/generic_vfs.go +@@ -332,6 +332,18 @@ func genericVFSCreateVolumeFromMigration(d Driver, initVolume func(vol Volume) ( + wrapper = localMigration.ProgressTracker(op, "block_progress", volName) + } + ++ // The filesystem stream may have replaced a block file inside the volume with a symlink. ++ if strings.HasPrefix(path, internalUtil.AddSlash(vol.MountPath())) { ++ fi, err := os.Lstat(path) ++ if err != nil { ++ return err ++ } ++ ++ if !fi.Mode().IsRegular() { ++ return fmt.Errorf("Block volume file %q isn't a regular file", path) ++ } ++ } ++ + // Reset the disk. + err := linux.ClearBlock(path, 0) + if err != nil { +-- +2.47.3 diff -Nru incus-6.0.4/debian/patches/152-GHSA-x8gj-2q73-qr6j.patch incus-6.0.4/debian/patches/152-GHSA-x8gj-2q73-qr6j.patch --- incus-6.0.4/debian/patches/152-GHSA-x8gj-2q73-qr6j.patch 1970-01-01 00:00:00.000000000 +0000 +++ incus-6.0.4/debian/patches/152-GHSA-x8gj-2q73-qr6j.patch 2026-09-25 14:26:49.000000000 +0000 @@ -0,0 +1,43 @@ +From 321e4bc3ec2953b3ff31405d439f302b60d6c357 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?St=C3=A9phane=20Graber?= +Date: Tue, 15 Sep 2026 22:25:57 -0400 +Subject: [PATCH] incusd/storage/buckets: Require can_edit to read bucket keys +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Bucket keys carry the S3 secret key, so reading them shouldn't be +covered by the read-only access restricted clients get to resources in +the default project. + +This addresses GHSA-x8gj-2q73-qr6j (CVE pending) + +Signed-off-by: Stéphane Graber +--- + cmd/incusd/storage_buckets.go | 4 ++-- + 1 file changed, 2 insertions(+), 2 deletions(-) + +diff --git a/cmd/incusd/storage_buckets.go b/cmd/incusd/storage_buckets.go +index 621d1fa073..853c62597d 100644 +--- a/cmd/incusd/storage_buckets.go ++++ b/cmd/incusd/storage_buckets.go +@@ -53,7 +53,7 @@ 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")}, + } + +@@ -61,7 +61,7 @@ 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")}, + } + +-- +2.47.3 diff -Nru incus-6.0.4/debian/patches/153-GHSA-jh4v-j34r-2mgh.patch incus-6.0.4/debian/patches/153-GHSA-jh4v-j34r-2mgh.patch --- incus-6.0.4/debian/patches/153-GHSA-jh4v-j34r-2mgh.patch 1970-01-01 00:00:00.000000000 +0000 +++ incus-6.0.4/debian/patches/153-GHSA-jh4v-j34r-2mgh.patch 2026-09-25 14:26:49.000000000 +0000 @@ -0,0 +1,39 @@ +From 8a9e061c225f58d6aa8269d71f7fbda267549bef Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?St=C3=A9phane=20Graber?= +Date: Sat, 29 Aug 2026 19:03:13 -0400 +Subject: [PATCH] incusd/storage: Treat volume creation with a source as a copy +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +The source volume access check added for CVE-2026-55621 only covers +requests with an explicit "copy" source type, but a request with an +empty type and a source name reaches the same copy code path without +it. Normalize such requests to a copy before dispatching. + +This addresses GHSA-jh4v-j34r-2mgh (CVE pending) + +Signed-off-by: Stéphane Graber +Rebased-by: Mathias Gibbens +--- + cmd/incusd/storage_volumes.go | 5 +++++ + 1 file changed, 5 insertions(+) + +diff --git a/cmd/incusd/storage_volumes.go b/cmd/incusd/storage_volumes.go +index d62a047be8..f0a4e0c7ab 100644 +--- a/cmd/incusd/storage_volumes.go ++++ b/cmd/incusd/storage_volumes.go +@@ -749,6 +749,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 "": + return doVolumeCreateOrCopy(s, r, request.ProjectParam(r), projectName, poolName, &req) +-- +2.47.3 diff -Nru incus-6.0.4/debian/patches/154-GHSA-mfwv-x733-9446.patch incus-6.0.4/debian/patches/154-GHSA-mfwv-x733-9446.patch --- incus-6.0.4/debian/patches/154-GHSA-mfwv-x733-9446.patch 1970-01-01 00:00:00.000000000 +0000 +++ incus-6.0.4/debian/patches/154-GHSA-mfwv-x733-9446.patch 2026-09-25 14:30:07.000000000 +0000 @@ -0,0 +1,259 @@ +From ae5f19d75b20bf9287c4911d208b63f4c7d46ccd Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?St=C3=A9phane=20Graber?= +Date: Sat, 12 Sep 2026 22:18:58 -0400 +Subject: [PATCH] incusd/operations: Check project access on operation get and + wait +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +The single operation GET and wait endpoints rendered any operation to +any trusted client, regardless of the project it belongs to. Apply the +same can_view_operations check as the operation listing. Waiting with a +valid operation secret is unchanged so untrusted remote servers can still +follow operations they were handed. + +This addresses GHSA-mfwv-x733-9446 (CVE pending) + +Signed-off-by: Stéphane Graber +Rebased-by: Mathias Gibbens +--- + cmd/incusd/operations.go | 115 +++++++++++++++++++++++++++++---------- + 1 file changed, 87 insertions(+), 28 deletions(-) + +diff --git a/cmd/incusd/operations.go b/cmd/incusd/operations.go +index e6e5afb693..2c96549c22 100644 +--- a/cmd/incusd/operations.go ++++ b/cmd/incusd/operations.go +@@ -132,6 +132,75 @@ func waitForOperations(ctx context.Context, clusterDB *db.Cluster, consoleShutdo + + // API functions + ++// 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 { ++ 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 +@@ -191,6 +260,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) +@@ -277,33 +351,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) + } + } + +@@ -991,8 +1043,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 != "" && op.Metadata()["secret"] != secret { +- return response.Forbidden(nil) ++ if secret != "" { ++ if 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 + +From c11b924ddda29fda6835bdf975d7e804974296eb Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?St=C3=A9phane=20Graber?= +Date: Sat, 12 Sep 2026 22:54:05 -0400 +Subject: [PATCH] incusd/operations: Hide access token operations from + non-admins +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Certificate add and cluster join token operations carry the token secret +in their metadata and live in the default project, so any client with +operation access to that project could read them. Limit them to callers +allowed to create such tokens. + +Signed-off-by: Stéphane Graber +Rebased-by: Mathias Gibbens +--- + cmd/incusd/operations.go | 28 +++++++++++++++++++++++++++- + 1 file changed, 27 insertions(+), 1 deletion(-) + +diff --git a/cmd/incusd/operations.go b/cmd/incusd/operations.go +index ac0d2c6d5b..fd55860d34 100644 +--- a/cmd/incusd/operations.go ++++ b/cmd/incusd/operations.go +@@ -132,6 +132,19 @@ 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 { +@@ -180,12 +193,17 @@ func operationCheckPermission(s *state.State, r *http.Request, op *operations.Op + // 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) ++ err = s.Authorizer.CheckPermission(r.Context(), r, auth.ObjectProject(projectName), auth.EntitlementCanViewOperations) + if err == nil { + return nil + } +@@ -589,6 +607,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 { +@@ -617,6 +639,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 { +-- +2.47.3 diff -Nru incus-6.0.4/debian/patches/155-GHSA-h85r-gjgx-g2rv-GHSA-27q7-qwhm-c34p.patch incus-6.0.4/debian/patches/155-GHSA-h85r-gjgx-g2rv-GHSA-27q7-qwhm-c34p.patch --- incus-6.0.4/debian/patches/155-GHSA-h85r-gjgx-g2rv-GHSA-27q7-qwhm-c34p.patch 1970-01-01 00:00:00.000000000 +0000 +++ incus-6.0.4/debian/patches/155-GHSA-h85r-gjgx-g2rv-GHSA-27q7-qwhm-c34p.patch 2026-09-25 14:26:49.000000000 +0000 @@ -0,0 +1,85 @@ +From 94f5dc2932306c28cd825bc96ee926a0fa0c10ef Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?St=C3=A9phane=20Graber?= +Date: Tue, 1 Sep 2026 09:57:38 -0400 +Subject: [PATCH] incusd/storage/drivers: Confine btrfs subvolume paths +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +BTRFSSubVolume.Path from a backup optimized header or a migration +header was joined to the volume mount path and passed to root-run +os.Remove/os.Rename/property-set without any containment, letting a +crafted ../ escape the pool. Validate every path at each decode site. + +This addresses GHSA-h85r-gjgx-g2rv and GHSA-27q7-qwhm-c34p (CVEs pending) + +Signed-off-by: Stéphane Graber +--- + .../storage/drivers/driver_btrfs_utils.go | 18 ++++++++++++++++++ + .../storage/drivers/driver_btrfs_volumes.go | 10 ++++++++++ + 2 files changed, 28 insertions(+) + +diff --git a/internal/server/storage/drivers/driver_btrfs_utils.go b/internal/server/storage/drivers/driver_btrfs_utils.go +index 7995f115f32..e00087045a2 100644 +--- a/internal/server/storage/drivers/driver_btrfs_utils.go ++++ b/internal/server/storage/drivers/driver_btrfs_utils.go +@@ -523,6 +523,19 @@ type BTRFSMetaDataHeader struct { + Subvolumes []BTRFSSubVolume `json:"subvolumes" yaml:"subvolumes"` // Sub volumes inside the volume (including the top level ones). + } + ++// validate ensures every subvolume path stays within the volume once joined to its mount path. ++// It must be called on any header decoded from untrusted input (backup tarballs, migration wire). ++func (h *BTRFSMetaDataHeader) validate() error { ++ for _, subVol := range h.Subvolumes { ++ rel := strings.TrimPrefix(subVol.Path, string(filepath.Separator)) ++ if rel != "" && !filepath.IsLocal(rel) { ++ return fmt.Errorf("Invalid subvolume path %q", subVol.Path) ++ } ++ } ++ ++ return nil ++} ++ + // restorationHeader scans the volume and any specified snapshots, returning a header containing subvolume metadata + // for use in restoring a volume and its snapshots onto another system. The metadata returned represents how the + // subvolumes should be restored, not necessarily how they are on disk now. Most of the time this is the same, +@@ -598,6 +611,11 @@ func (d *btrfs) loadOptimizedBackupHeader(r io.ReadSeeker, mountPath string, bas + return nil, fmt.Errorf("Error parsing optimized backup header file: %w", err) + } + ++ err = header.validate() ++ if err != nil { ++ return nil, err ++ } ++ + cancelFunc() + return &header, nil + } +diff --git a/internal/server/storage/drivers/driver_btrfs_volumes.go b/internal/server/storage/drivers/driver_btrfs_volumes.go +index f3a571c311a..08a2eb08ccc 100644 +--- a/internal/server/storage/drivers/driver_btrfs_volumes.go ++++ b/internal/server/storage/drivers/driver_btrfs_volumes.go +@@ -562,6 +562,11 @@ func (d *btrfs) CreateVolumeFromMigration(vol Volume, conn io.ReadWriteCloser, v + return fmt.Errorf("Failed decoding BTRFS migration header: %w", err) + } + ++ err = migrationHeader.validate() ++ if err != nil { ++ return err ++ } ++ + d.logger.Debug("Received BTRFS migration meta data header", logger.Ctx{"name": vol.name}) + } else { + // Populate the migrationHeader subvolumes with root volumes only to support older sources. +@@ -1536,6 +1541,11 @@ func (d *btrfs) MigrateVolume(vol Volume, conn io.ReadWriteCloser, volSrcArgs *l + return fmt.Errorf("Failed decoding BTRFS migration header: %w", err) + } + ++ err = migrationHeader.validate() ++ if err != nil { ++ return err ++ } ++ + d.logger.Debug("Received BTRFS migration meta data header", logger.Ctx{"name": vol.name}) + + volSrcArgs.Snapshots = []string{} diff -Nru incus-6.0.4/debian/patches/series incus-6.0.4/debian/patches/series --- incus-6.0.4/debian/patches/series 2026-09-02 16:39:28.000000000 +0000 +++ incus-6.0.4/debian/patches/series 2026-09-25 14:26:49.000000000 +0000 @@ -56,3 +56,10 @@ 146-incus-7.3-fixes.patch 147-CVE-2026-81500.patch 148-CVE-2026-81501.patch +149-cherry-pick-rsync-xattrs-transfer.patch +150-GHSA-4cph-ccqv-hm3c.patch +151-GHSA-579w-c4rw-c8q3.patch +152-GHSA-x8gj-2q73-qr6j.patch +153-GHSA-jh4v-j34r-2mgh.patch +154-GHSA-mfwv-x733-9446.patch +155-GHSA-h85r-gjgx-g2rv-GHSA-27q7-qwhm-c34p.patch