[Date Prev][Date Next] [Thread Prev][Thread Next] [Date Index] [Thread Index]

Bug#1112283: trixie-pu: package nova/31.0.0-6



Package: release.debian.org
Severity: normal
Tags: trixie
X-Debbugs-Cc: nova@packages.debian.org
Control: affects -1 + src:nova
User: release.debian.org@packages.debian.org
Usertags: pu

Hi,

[ Reason ]
I'd like to update Nova in Trixie to close this bug:
https://bugs.debian.org/1111689

For details, you may read the release notes that are
in the attached debdiff, also available here:
https://opendev.org/openstack/nova/src/commit/a7e5377da4c0199443c76802d3dde494d7bea474/releasenotes/notes/bug-2112187-e1c1d40f090e421b.yaml

[ Impact ]
In some conditions, a volume may be mounted on a wrong
VM, giving access to data to a wrong customer.

[ Tests ]
Upstream has intensive unit and functional test that I
also run at build time and autopkgtest (for unit tests),
and on my own package-based CI (for functional tests).

[ Risks ]
Not much risk thanks to the testing described above.

[ Checklist ]
  [x] *all* changes are documented in the d/changelog
  [x] I reviewed all changes and I approve them
  [x] attach debdiff against the package in (old)stable
  [x] the issue is verified as fixed in unstable

Please allow me to upload nova/31.0.0-6+deb13u1 as per
attached debdiff.

Cheers,

Thomas Goirand (zigo)
diff -Nru nova-31.0.0/debian/changelog nova-31.0.0/debian/changelog
--- nova-31.0.0/debian/changelog	2025-07-12 11:35:02.000000000 +0200
+++ nova-31.0.0/debian/changelog	2025-08-21 09:10:49.000000000 +0200
@@ -1,3 +1,17 @@
+nova (2:31.0.0-6+deb13u1) trixie; urgency=high
+
+  * A vulnerability has been identified in OpenStack Nova and OpenStack Watcher
+    in conjunction with volume swap operations performed by the Watcher
+    service. Under specific circumstances, this can lead to a situation where
+    two Nova libvirt instances could reference the same block device, allowing
+    accidental information disclosure to the unauthorized instance. Added
+    upstream patch: OSSN-0094_restrict_swap_volume_to_cinder.patch.
+    (Closes: #1111689).
+  * Blacklist non-deterministic unit test:
+    - ComputeTestCase.test_add_remove_fixed_ip_updates_instance_updated_at
+
+ -- Thomas Goirand <zigo@debian.org>  Thu, 21 Aug 2025 09:10:49 +0200
+
 nova (2:31.0.0-6) unstable; urgency=medium
 
   * Also do it for nova-api-metadata.
diff -Nru nova-31.0.0/debian/patches/OSSN-0094_restrict_swap_volume_to_cinder.patch nova-31.0.0/debian/patches/OSSN-0094_restrict_swap_volume_to_cinder.patch
--- nova-31.0.0/debian/patches/OSSN-0094_restrict_swap_volume_to_cinder.patch	1970-01-01 01:00:00.000000000 +0100
+++ nova-31.0.0/debian/patches/OSSN-0094_restrict_swap_volume_to_cinder.patch	2025-08-21 09:10:49.000000000 +0200
@@ -0,0 +1,637 @@
+Author: Sean Mooney <work@seanmooney.info>
+Date: Fri, 15 Aug 2025 14:33:34 +0100
+Description: restrict swap volume to cinder
+ This change tightens the validation around the attachment
+ update API to ensure that it can only be called if the source
+ volume has a non empty migration status.
+ .
+ That means it will only accept a request to swap the volume if
+ it is the result of a cinder volume migration.
+ .
+ This change is being made to prevent the instance domain
+ XML from getting out of sync with the nova BDM records
+ and cinder connection info. In the future support for direct
+ swap volume actions can be re-added if and only if the
+ nova libvirt driver is updated to correctly modify the domain.
+ The libvirt driver is the only driver that supported this API
+ outside of a cinder orchestrated swap volume.
+ .
+ By allowing the domain XML and BDMs to get out of sync
+ if an admin later live-migrates the VM the host path will not be
+ modified for the destination host. Normally this results in a live
+ migration failure which often prompts the admin to cold migrate instead.
+ however if the source device path exists on the destination the migration
+ will proceed. This can lead to 2 VMs using the same host block device.
+ At best this will cause a crash or data corruption.
+ At worst it will allow one guest to access the data of another.
+ .
+ Prior to this change there was an explicit warning in nova API ref
+ stating that humans should never call this API because it can lead
+ to this situation. Now it considered a hard error due to the
+ security implications.
+Bug: https://launchpad.net/bugs/2112187
+Depends-on: https://review.opendev.org/c/openstack/tempest/+/957753
+Change-Id: I439338bd2f27ccd65a436d18c8cbc9c3127ee612
+Signed-off-by: Sean Mooney <work@seanmooney.info>
+Origin: upstream, https://review.opendev.org/c/openstack/nova/+/957759
+
+diff --git a/api-ref/source/os-volume-attachments.inc b/api-ref/source/os-volume-attachments.inc
+index 803d59d..bf5d627 100644
+--- a/api-ref/source/os-volume-attachments.inc
++++ b/api-ref/source/os-volume-attachments.inc
+@@ -185,16 +185,16 @@
+ .. note:: This action only valid when the server is in ACTIVE, PAUSED and RESIZED state,
+           or a conflict(409) error will be returned.
+ 
+-.. warning:: When updating volumeId, this API is typically meant to
+-             only be used as part of a larger orchestrated volume
+-             migration operation initiated in the block storage
+-             service via the ``os-retype`` or ``os-migrate_volume``
+-             volume actions. Direct usage of this API to update
+-             volumeId is not recommended and may result in needing to
+-             hard reboot the server to update details within the guest
+-             such as block storage serial IDs. Furthermore, updating
+-             volumeId via this API is only implemented by `certain
+-             compute drivers`_.
++.. Important::
++
++   When updating volumeId, this API **MUST**  only be used
++   as part of a larger orchestrated volume
++   migration operation initiated in the block storage
++   service via the ``os-retype`` or ``os-migrate_volume``
++   volume actions. Direct usage of this API is not supported
++   and will be blocked by nova with a 409 conflict.
++   Furthermore, updating ``volumeId`` via this API is only
++   implemented by `certain compute drivers`_.
+ 
+ .. _certain compute drivers: https://docs.openstack.org/nova/latest/user/support-matrix.html#operation_swap_volume
+ 
+diff --git a/nova/api/openstack/compute/volumes.py b/nova/api/openstack/compute/volumes.py
+index 3f5deaa..f04c3b7 100644
+--- a/nova/api/openstack/compute/volumes.py
++++ b/nova/api/openstack/compute/volumes.py
+@@ -434,6 +434,12 @@
+         except exception.VolumeNotFound as e:
+             raise exc.HTTPNotFound(explanation=e.format_message())
+ 
++        if ('migration_status' not in old_volume or
++            old_volume['migration_status'] in (None, '')):
++            message = (f"volume {old_volume_id} is not migrating this api "
++                       "should only be called by Cinder")
++            raise exc.HTTPConflict(explanation=message)
++
+         new_volume_id = body['volumeAttachment']['volumeId']
+         try:
+             new_volume = self.volume_api.get(context, new_volume_id)
+diff --git a/nova/tests/fixtures/cinder.py b/nova/tests/fixtures/cinder.py
+index 049d22e..732c050 100644
+--- a/nova/tests/fixtures/cinder.py
++++ b/nova/tests/fixtures/cinder.py
+@@ -262,11 +262,15 @@
+                     'attachment_id': attachment['id'],
+                     'mountpoint': '/dev/vdb',
+                 }
+-
++            migration_status = (
++                None if volume_id not in (
++                    self.SWAP_OLD_VOL, self.SWAP_ERR_OLD_VOL)
++                else "migrating")
+             volume.update({
+                 'status': 'in-use',
+                 'attach_status': 'attached',
+                 'attachments': attachments,
++                'migration_status': migration_status
+             })
+         # Otherwise mark the volume as available and detached
+         else:
+diff --git a/nova/tests/functional/notification_sample_tests/test_instance.py b/nova/tests/functional/notification_sample_tests/test_instance.py
+index fcb2812..3e52590 100644
+--- a/nova/tests/functional/notification_sample_tests/test_instance.py
++++ b/nova/tests/functional/notification_sample_tests/test_instance.py
+@@ -1560,8 +1560,14 @@
+     def test_volume_swap_server_with_error(self):
+         server = self._do_setup_server_and_error_flag()
+ 
+-        self._volume_swap_server(server, self.cinder.SWAP_ERR_OLD_VOL,
+-                                 self.cinder.SWAP_ERR_NEW_VOL)
++        # This is calling swap volume but we are emulating cinder
++        # volume migrate in the fixture to allow this.
++        # i.e. this is simulating the workflow where you move a volume
++        # between cinder backend using a temp volume that cinder internally
++        # cleans up at the end of the migration.
++        self._volume_swap_server(
++            server, self.cinder.SWAP_ERR_OLD_VOL,
++            self.cinder.SWAP_ERR_NEW_VOL)
+         self._wait_for_notification('compute.exception')
+ 
+         # Eight versioned notifications are generated.
+@@ -1576,6 +1582,8 @@
+         self.assertLessEqual(7, len(self.notifier.versioned_notifications),
+                              'Unexpected number of versioned notifications. '
+                              'Got: %s' % self.notifier.versioned_notifications)
++        # the block device mapping is using SWAP_ERR_OLD_VOL because this is
++        # the cinder volume migrate workflow.
+         block_devices = [{
+             "nova_object.data": {
+                 "boot_index": None,
+diff --git a/nova/tests/functional/regressions/test_bug_1943431.py b/nova/tests/functional/regressions/test_bug_1943431.py
+index 69c900c..5e945de 100644
+--- a/nova/tests/functional/regressions/test_bug_1943431.py
++++ b/nova/tests/functional/regressions/test_bug_1943431.py
+@@ -16,6 +16,7 @@
+ 
+ from nova import context
+ from nova import objects
++from nova.tests.functional.api import client
+ from nova.tests.functional import integrated_helpers
+ from nova.tests.functional.libvirt import base
+ from nova.virt import block_device as driver_block_device
+@@ -46,6 +47,8 @@
+         self.start_compute()
+ 
+     def test_ro_multiattach_swap_volume(self):
++        # NOTE(sean-k-mooney): This test is emulating calling swap volume
++        # directly instead of using cinder volume migrate or retype.
+         server_id = self._create_server(networks='none')['id']
+         self.api.post_server_volume(
+             server_id,
+@@ -58,47 +61,13 @@
+         self._wait_for_volume_attach(
+             server_id, self.cinder.MULTIATTACH_RO_SWAP_OLD_VOL)
+ 
+-        # Swap between the old and new volumes
+-        self.api.put_server_volume(
+-            server_id,
+-            self.cinder.MULTIATTACH_RO_SWAP_OLD_VOL,
++        # NOTE(sean-k-mooney): because of bug 212187 directly using
++        # swap volume is not supported and should fail.
++        ex = self.assertRaises(
++            client.OpenStackApiException, self.api.put_server_volume,
++            server_id, self.cinder.MULTIATTACH_RO_SWAP_OLD_VOL,
+             self.cinder.MULTIATTACH_RO_SWAP_NEW_VOL)
+-
+-        # Wait until the old volume is detached and new volume is attached
+-        self._wait_for_volume_detach(
+-            server_id, self.cinder.MULTIATTACH_RO_SWAP_OLD_VOL)
+-        self._wait_for_volume_attach(
+-            server_id, self.cinder.MULTIATTACH_RO_SWAP_NEW_VOL)
+-
+-        bdm = objects.BlockDeviceMapping.get_by_volume_and_instance(
+-            context.get_admin_context(),
+-            self.cinder.MULTIATTACH_RO_SWAP_NEW_VOL,
+-            server_id)
+-        connection_info = jsonutils.loads(bdm.connection_info)
+-
+-        # Assert that only the new volume UUID is referenced within the stashed
+-        # connection_info and returned by driver_block_device.get_volume_id
+-        self.assertIn('volume_id', connection_info.get('data'))
+-        self.assertEqual(
+-            self.cinder.MULTIATTACH_RO_SWAP_NEW_VOL,
+-            connection_info['data']['volume_id'])
+-        self.assertIn('volume_id', connection_info)
+-        self.assertEqual(
+-            self.cinder.MULTIATTACH_RO_SWAP_NEW_VOL,
+-            connection_info['volume_id'])
+-        self.assertIn('serial', connection_info)
+-        self.assertEqual(
+-            self.cinder.MULTIATTACH_RO_SWAP_NEW_VOL,
+-            connection_info.get('serial'))
+-        self.assertEqual(
+-            self.cinder.MULTIATTACH_RO_SWAP_NEW_VOL,
+-            driver_block_device.get_volume_id(connection_info))
+-
+-        # Assert that the new volume can be detached from the instance
+-        self.api.delete_server_volume(
+-            server_id, self.cinder.MULTIATTACH_RO_SWAP_NEW_VOL)
+-        self._wait_for_volume_detach(
+-            server_id, self.cinder.MULTIATTACH_RO_SWAP_NEW_VOL)
++        self.assertIn("this api should only be called by Cinder", str(ex))
+ 
+     def test_ro_multiattach_migrate_volume(self):
+         server_id = self._create_server(networks='none')['id']
+diff --git a/nova/tests/functional/regressions/test_bug_2112187.py b/nova/tests/functional/regressions/test_bug_2112187.py
+new file mode 100644
+index 0000000..276e919
+--- /dev/null
++++ b/nova/tests/functional/regressions/test_bug_2112187.py
+@@ -0,0 +1,67 @@
++# Licensed under the Apache License, Version 2.0 (the "License"); you may
++# not use this file except in compliance with the License. You may obtain
++# a copy of the License at
++#
++#      http://www.apache.org/licenses/LICENSE-2.0
++#
++# Unless required by applicable law or agreed to in writing, software
++# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
++# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
++# License for the specific language governing permissions and limitations
++# under the License.
++
++from nova.tests.functional.api import client
++from nova.tests.functional import integrated_helpers
++from nova.tests.functional.libvirt import base
++
++
++class TestDirectSwapVolume(
++    base.ServersTestBase,
++    integrated_helpers.InstanceHelperMixin
++):
++    """Regression test for bug 2112187
++
++    During a Cinder orchestrated volume migration nova leaves the
++    stashed connection_info of the attachment pointing at the original
++    volume UUID used during the migration because cinder will atomically
++    revert the UUID of the volume back to the original value.
++
++    When swap volume is used directly the uuid should be updated
++    in the libvirt xml but nova does not support that today.
++    That results in the uuid in the xml and the uuid in the BDMs
++    being out of sync.
++
++    As a result it is unsafe to allow direct swap volume.
++    """
++
++    microversion = 'latest'
++    ADMIN_API = True
++
++    def setUp(self):
++        super().setUp()
++        self.start_compute()
++
++    def test_direct_swap_volume(self):
++        # NOTE(sean-k-mooney): This test is emulating calling swap volume
++        # directly instead of using cinder volume migrate or retype.
++        server_id = self._create_server(networks='none')['id']
++        # We do not need to use a multiattach volume but any volume
++        # that does not have a migration state set will work.
++        self.api.post_server_volume(
++            server_id,
++            {
++                'volumeAttachment': {
++                    'volumeId': self.cinder.MULTIATTACH_RO_SWAP_OLD_VOL
++                }
++            }
++        )
++        self._wait_for_volume_attach(
++            server_id, self.cinder.MULTIATTACH_RO_SWAP_OLD_VOL)
++
++        # NOTE(sean-k-mooney): because of bug 212187 directly using
++        # swap volume is not supported and should fail.
++        ex = self.assertRaises(
++            client.OpenStackApiException, self.api.put_server_volume,
++            server_id, self.cinder.MULTIATTACH_RO_SWAP_OLD_VOL,
++            self.cinder.MULTIATTACH_RO_SWAP_NEW_VOL)
++        self.assertIn("this api should only be called by Cinder", str(ex))
+diff --git a/nova/tests/unit/api/openstack/compute/test_volumes.py b/nova/tests/unit/api/openstack/compute/test_volumes.py
+index d3d5c35..6ff33a3 100644
+--- a/nova/tests/unit/api/openstack/compute/test_volumes.py
++++ b/nova/tests/unit/api/openstack/compute/test_volumes.py
+@@ -15,14 +15,16 @@
+ #    under the License.
+ 
+ import datetime
+-from unittest import mock
+ import urllib
+ 
++from unittest import mock
++
+ import fixtures
++import webob
++
+ from oslo_serialization import jsonutils
+ from oslo_utils import encodeutils
+ from oslo_utils.fixture import uuidsentinel as uuids
+-import webob
+ from webob import exc
+ 
+ from nova.api.openstack import api_version_request
+@@ -65,17 +67,28 @@
+     return fake_instance.fake_instance_obj(
+         context, id=1, uuid=instance_id, project_id=context.project_id)
+ 
++# TODO(sean-k-mooney): this is duplicated in the policy tests
++# we should consider consolidating this.
++
+ 
+ def fake_get_volume(self, context, id):
++    migration_status = None
+     if id == FAKE_UUID_A:
+         status = 'in-use'
+         attach_status = 'attached'
+     elif id == FAKE_UUID_B:
+         status = 'available'
+         attach_status = 'detached'
++    elif id == uuids.source_swap_vol:
++        status = 'in-use'
++        attach_status = 'attached'
++        migration_status = 'migrating'
+     else:
+         raise exception.VolumeNotFound(volume_id=id)
+-    return {'id': id, 'status': status, 'attach_status': attach_status}
++    return {
++        'id': id, 'status': status, 'attach_status': attach_status,
++        'migration_status': migration_status
++    }
+ 
+ 
+ def fake_create_snapshot(self, context, volume, name, description):
+@@ -99,7 +112,7 @@
+ 
+ @classmethod
+ def fake_bdm_get_by_volume_and_instance(cls, ctxt, volume_id, instance_uuid):
+-    if volume_id != FAKE_UUID_A:
++    if volume_id not in (FAKE_UUID_A, uuids.source_swap_vol):
+         raise exception.VolumeBDMNotFound(volume_id=volume_id)
+     db_bdm = fake_block_device.FakeDbBlockDeviceDict({
+         'id': 1,
+@@ -110,7 +123,7 @@
+         'source_type': 'volume',
+         'destination_type': 'volume',
+         'snapshot_id': None,
+-        'volume_id': FAKE_UUID_A,
++        'volume_id': volume_id,
+         'volume_size': 1,
+         'attachment_id': uuids.attachment_id
+     })
+@@ -572,6 +585,7 @@
+             test.MatchType(objects.Instance),
+             {'attach_status': 'attached',
+              'status': 'in-use',
++             'migration_status': None,
+              'id': FAKE_UUID_A})
+ 
+     @mock.patch.object(compute_api.API, 'detach_volume')
+@@ -585,7 +599,8 @@
+             test.MatchType(objects.Instance),
+             {'attach_status': 'attached',
+              'status': 'in-use',
+-             'id': FAKE_UUID_A})
++             'id': FAKE_UUID_A,
++             'migration_status': None})
+ 
+     def test_attach_volume(self):
+         self.stub_out('nova.compute.api.API.attach_volume',
+@@ -739,7 +754,7 @@
+         self.assertRaises(exc.HTTPBadRequest, self.attachments.create,
+                           req, FAKE_UUID, body=body)
+ 
+-    def _test_swap(self, attachments, uuid=FAKE_UUID_A, body=None):
++    def _test_swap(self, attachments, uuid=uuids.source_swap_vol, body=None):
+         body = body or {'volumeAttachment': {'volumeId': FAKE_UUID_B}}
+         return attachments.update(self.req, uuids.instance, uuid, body=body)
+ 
+@@ -754,10 +769,13 @@
+             self.req.environ['nova.context'], test.MatchType(objects.Instance),
+             {'attach_status': 'attached',
+              'status': 'in-use',
+-             'id': FAKE_UUID_A},
++             'id': uuids.source_swap_vol,
++             'migration_status': 'migrating'
++             },
+             {'attach_status': 'detached',
+              'status': 'available',
+-             'id': FAKE_UUID_B})
++             'id': FAKE_UUID_B,
++             'migration_status': None})
+ 
+     @mock.patch.object(compute_api.API, 'swap_volume')
+     def test_swap_volume(self, mock_swap_volume):
+@@ -774,10 +792,12 @@
+             self.req.environ['nova.context'], test.MatchType(objects.Instance),
+             {'attach_status': 'attached',
+              'status': 'in-use',
+-             'id': FAKE_UUID_A},
++             'id': uuids.source_swap_vol,
++             'migration_status': 'migrating'},
+             {'attach_status': 'detached',
+              'status': 'available',
+-             'id': FAKE_UUID_B})
++             'id': FAKE_UUID_B,
++             'migration_status': None})
+ 
+     def test_swap_volume_with_nonexistent_uri(self):
+         self.assertRaises(exc.HTTPNotFound, self._test_swap,
+@@ -786,13 +806,14 @@
+     @mock.patch.object(cinder.API, 'get')
+     def test_swap_volume_with_nonexistent_dest_in_body(self, mock_get):
+         mock_get.side_effect = [
+-            None, exception.VolumeNotFound(volume_id=FAKE_UUID_C)]
++            fake_get_volume(None, None, uuids.source_swap_vol),
++            exception.VolumeNotFound(volume_id=FAKE_UUID_C)]
+         body = {'volumeAttachment': {'volumeId': FAKE_UUID_C}}
+         with mock.patch.object(self.attachments, '_update_volume_regular'):
+             self.assertRaises(exc.HTTPBadRequest, self._test_swap,
+                               self.attachments, body=body)
+         mock_get.assert_has_calls([
+-            mock.call(self.req.environ['nova.context'], FAKE_UUID_A),
++            mock.call(self.req.environ['nova.context'], uuids.source_swap_vol),
+             mock.call(self.req.environ['nova.context'], FAKE_UUID_C)])
+ 
+     def test_swap_volume_without_volumeId(self):
+@@ -823,8 +844,9 @@
+                           self.attachments)
+         if mock_bdm.called:
+             # New path includes regular PUT procedure
+-            mock_bdm.assert_called_once_with(self.req.environ['nova.context'],
+-                                             FAKE_UUID_A, uuids.instance)
++            mock_bdm.assert_called_once_with(
++                self.req.environ['nova.context'],
++                uuids.source_swap_vol, uuids.instance)
+             mock_swap_volume.assert_not_called()
+         else:
+             # Old path is pure swap-volume
+@@ -834,10 +856,12 @@
+                 test.MatchType(objects.Instance),
+                 {'attach_status': 'attached',
+                  'status': 'in-use',
+-                 'id': FAKE_UUID_A},
++                 'migration_status': 'migrating',
++                 'id': uuids.source_swap_vol},
+                 {'attach_status': 'detached',
+                  'status': 'available',
+-                 'id': FAKE_UUID_B})
++                 'id': FAKE_UUID_B,
++                 'migration_status': None})
+ 
+     def _test_list_with_invalid_filter(self, url):
+         req = self._build_request(url)
+@@ -1191,7 +1215,7 @@
+             self.context,
+             id=1,
+             instance_uuid=FAKE_UUID,
+-            volume_id=FAKE_UUID_A,
++            volume_id=uuids.source_swap_vol,
+             source_type='volume',
+             destination_type='volume',
+             delete_on_termination=False,
+@@ -1306,7 +1330,7 @@
+             self.context,
+             id=1,
+             instance_uuid=FAKE_UUID,
+-            volume_id=FAKE_UUID_A,
++            volume_id=uuids.source_swap_vol,
+             source_type='volume',
+             destination_type='volume',
+             delete_on_termination=False,
+@@ -1322,7 +1346,7 @@
+             'delete_on_termination': True,
+         }}
+         self.attachments.update(self.req, FAKE_UUID,
+-                                FAKE_UUID_A, body=body)
++                                uuids.source_swap_vol, body=body)
+         mock_bdm_save.assert_called_once()
+         self.assertTrue(vol_bdm['delete_on_termination'])
+         # Swap volume is tested elsewhere, just make sure that we did
+@@ -1339,7 +1363,7 @@
+             self.context,
+             id=1,
+             instance_uuid=FAKE_UUID,
+-            volume_id=FAKE_UUID_A,
++            volume_id=uuids.source_swap_vol,
+             source_type='volume',
+             destination_type='volume',
+             delete_on_termination=False,
+@@ -1354,7 +1378,7 @@
+         }}
+         req = self._get_req(body, microversion='2.84')
+         self.attachments.update(req, FAKE_UUID,
+-                                FAKE_UUID_A, body=body)
++                                uuids.source_swap_vol, body=body)
+         mock_swap.assert_called_once()
+         mock_bdm_save.assert_not_called()
+ 
+@@ -1640,6 +1664,7 @@
+                     'id': volume_id,
+                     'size': 1,
+                     'multiattach': True,
++                    'migration_status': 'migrating',
+                     'attachments': {
+                         uuids.server1: {
+                             'attachment_id': uuids.attachment_id1,
+@@ -1689,12 +1714,12 @@
+             ex = self.assertRaises(
+                 webob.exc.HTTPBadRequest, controller.update, req,
+                 uuids.server1, uuids.old_vol_id, body=body)
+-        self.assertIn('Swapping multi-attach volumes with more than one ',
+-                      str(ex))
+-        mock_attachment_get.assert_has_calls([
+-            mock.call(ctxt, uuids.attachment_id1),
+-            mock.call(ctxt, uuids.attachment_id2)], any_order=True)
+-        mock_roll_detaching.assert_called_once_with(ctxt, uuids.old_vol_id)
++            self.assertIn(
++                'Swapping multi-attach volumes with more than one ', str(ex))
++            mock_attachment_get.assert_has_calls([
++                mock.call(ctxt, uuids.attachment_id1),
++                mock.call(ctxt, uuids.attachment_id2)], any_order=True)
++            mock_roll_detaching.assert_called_once_with(ctxt, uuids.old_vol_id)
+ 
+ 
+ class CommonBadRequestTestCase(object):
+diff --git a/nova/tests/unit/policies/test_volumes.py b/nova/tests/unit/policies/test_volumes.py
+index 896881c..f4070a6 100644
+--- a/nova/tests/unit/policies/test_volumes.py
++++ b/nova/tests/unit/policies/test_volumes.py
+@@ -38,7 +38,7 @@
+ 
+ 
+ def fake_bdm_get_by_volume_and_instance(cls, ctxt, volume_id, instance_uuid):
+-    if volume_id != FAKE_UUID_A:
++    if volume_id not in (FAKE_UUID_A, uuids.source_swap_vol):
+         raise exception.VolumeBDMNotFound(volume_id=volume_id)
+     db_bdm = fake_block_device.FakeDbBlockDeviceDict(
+         {'id': 1,
+@@ -55,15 +55,23 @@
+ 
+ 
+ def fake_get_volume(self, context, id):
++    migration_status = None
+     if id == FAKE_UUID_A:
+         status = 'in-use'
+         attach_status = 'attached'
+     elif id == FAKE_UUID_B:
+         status = 'available'
+         attach_status = 'detached'
++    elif id == uuids.source_swap_vol:
++        status = 'in-use'
++        attach_status = 'attached'
++        migration_status = 'migrating'
+     else:
+         raise exception.VolumeNotFound(volume_id=id)
+-    return {'id': id, 'status': status, 'attach_status': attach_status}
++    return {
++        'id': id, 'status': status, 'attach_status': attach_status,
++        'migration_status': migration_status
++    }
+ 
+ 
+ class VolumeAttachPolicyTest(base.BasePolicyTest):
+@@ -163,9 +171,10 @@
+     def test_swap_volume_attach_policy(self, mock_swap_volume):
+         rule_name = self.policy_root % "swap"
+         body = {'volumeAttachment': {'volumeId': FAKE_UUID_B}}
+-        self.common_policy_auth(self.project_admin_authorized_contexts,
+-                                rule_name, self.controller.update,
+-                                self.req, FAKE_UUID, FAKE_UUID_A, body=body)
++        self.common_policy_auth(
++            self.project_admin_authorized_contexts,
++            rule_name, self.controller.update,
++            self.req, FAKE_UUID, uuids.source_swap_vol, body=body)
+ 
+     @mock.patch.object(block_device_obj.BlockDeviceMapping, 'save')
+     @mock.patch('nova.compute.api.API.swap_volume')
+@@ -198,9 +207,10 @@
+         req = fakes.HTTPRequest.blank('', version='2.85')
+         body = {'volumeAttachment': {'volumeId': FAKE_UUID_B,
+             'delete_on_termination': True}}
+-        self.common_policy_auth(self.project_admin_authorized_contexts,
+-                                rule_name, self.controller.update,
+-                                req, FAKE_UUID, FAKE_UUID_A, body=body)
++        self.common_policy_auth(
++            self.project_admin_authorized_contexts,
++            rule_name, self.controller.update,
++            req, FAKE_UUID, uuids.source_swap_vol, body=body)
+         mock_swap_volume.assert_called()
+         mock_bdm_save.assert_called()
+ 
+diff --git a/releasenotes/notes/bug-2112187-e1c1d40f090e421b.yaml b/releasenotes/notes/bug-2112187-e1c1d40f090e421b.yaml
+new file mode 100644
+index 0000000..bd7fc56
+--- /dev/null
++++ b/releasenotes/notes/bug-2112187-e1c1d40f090e421b.yaml
+@@ -0,0 +1,36 @@
++---
++security:
++  - |
++    Nova has documented that the ``update volume attachment`` API
++    PUT /servers/{server_id}/os-volume_attachments/{volume_id}
++    should not be called directly for a very long time.
++
++    "When updating volumeId, this API is typically meant to only
++    be used as part of a larger orchestrated volume migration
++    operation initiated in the block storage service via
++    the os-retype or os-migrate_volume volume actions.
++    Direct usage of this API to update volumeId is not recommended
++    and may result in needing to hard reboot the server
++    to update details within the guest such as block storage serial IDs.
++    Furthermore, updating volumeId via this API is only implemented
++    by certain compute drivers."
++
++    As an admin only api, direct usage has always been limited to admins
++    or service like ``watcher``.
++    This longstanding recommendation is now enforced as a security
++    hardening measure and restricted to only cinder.
++    The prior warning alluded to the fact that directly using this
++    api can result in a guest with a de-synced definition of the volume
++    serial. Before this change it was possible for an admin to unknowingly
++    put a VM in an inconsistent state such that a future live migration may
++    fail or succeed and break tenant isolation. This could not happen
++    when the api was called by cinder so Nova has restricted that api
++    exclusively to that use-case.
++    see: https://bugs.launchpad.net/nova/+bug/2112187 for details.
++
++fixes:
++  - |
++    ``Nova`` now strictly enforces that only ``cinder`` can call the
++    ``update volume attachment`` aka ``swap volume`` api. This is part
++    of addressing a security hardening gap identified as part of bug:
++    https://bugs.launchpad.net/nova/+bug/2112187
diff -Nru nova-31.0.0/debian/patches/series nova-31.0.0/debian/patches/series
--- nova-31.0.0/debian/patches/series	2025-07-12 11:35:02.000000000 +0200
+++ nova-31.0.0/debian/patches/series	2025-08-21 09:10:49.000000000 +0200
@@ -5,3 +5,4 @@
 fix-exception.NovaException.patch
 Add-context-switch-chance-to-other-thread-during-get_available_resources.patch
 Fix-neutron-client-dict-grabbing.patch
+OSSN-0094_restrict_swap_volume_to_cinder.patch
diff -Nru nova-31.0.0/debian/rules nova-31.0.0/debian/rules
--- nova-31.0.0/debian/rules	2025-07-12 11:35:02.000000000 +0200
+++ nova-31.0.0/debian/rules	2025-08-21 09:10:49.000000000 +0200
@@ -61,7 +61,9 @@
 ifeq (,$(findstring nocheck, $(DEB_BUILD_OPTIONS)))
 	# Fails on buildd:
 	# db.main.test_api.ArchiveTestCase.test_archive_deleted_rows_task_log
-	pkgos-dh_auto_test --no-py2 'nova\.tests\.unit\.(?!(.*virt.libvirt\.test_driver\.LibvirtConnTestCase\.test_spawn_with_config_drive.*|.*test_wsgi\.TestWSGIServerWithSSL.*|.*test_hacking\.HackingTestCase.*|.*CreateInstanceTypeTest\.test_name_with_non_printable_characters.*|.*PatternPropertiesTestCase\.test_validate_patternProperties_fails.*|.*virt\.libvirt\.test_driver\.LibvirtDriverTestCase\.test_get_disk_xml.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_detach_volume_with_vir_domain_affect_live_flag.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_update_volume_xml.*|.*console\.test_websocketproxy\.NovaProxyRequestHandlerTestCase\.test_tcp_rst_no_compute_rpcapi.*|.*virt\.libvirt\.test_blockinfo\.LibvirtBlockInfoTest\.test_get_disk_mapping_rescue_with_config.*|.*virt\.libvirt\.test_blockinfo\.LibvirtBlockInfoTest\.test_get_disk_mapping_stable_rescue_ide_cdrom.*|.*virt\.libvirt\.volume\.test_nvme\.LibvirtNVMEVolumeDriverTestCase\.test_libvirt_nvme_driver_connect.*|.*virt\.libvirt\.volume\.test_nvme\.LibvirtNVMEVolumeDriverTestCase\.test_libvirt_nvme_driver_disconnect.*|.*virt\.libvirt\.volume\.test_nvme\.LibvirtNVMEVolumeDriverTestCase\.test_libvirt_nvme_driver_get_config.*|.*virt\.libvirt\.volume\.test_scaleio\.LibvirtScaleIOVolumeDriverTestCase.*|.*virt\.libvirt\.test_driver\.LibvirtDriverTestCase\.test_cross_cell_move_rbd_flatten_fetch_image_cache.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_check_discard_for_attach_volume_blk_controller_no_unmap.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_check_discard_for_attach_volume_no_unmap.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_check_discard_for_attach_volume_valid_controller.*|.*virt\.libvirt\.test_driver\.LibvirtDriverTestCase\.test_rbd_image_flatten_during_fetch_image_cache.*|.*test_utils\.GenericUtilsTestCase\.test_temporary_chown.*|console\.test_websocketproxy\.NovaProxyRequestHandlerTestCase\.test_reject_open_redirect|console\.test_websocketproxy\.NovaProxyRequestHandlerTestCase\.test_reject_open_redirect_3_slashes|privsep\.test_utils\.SupportDirectIOTestCase\.test_supports_direct_io_with_exception_in_open|privsep\.test_utils\.SupportDirectIOTestCase\.test_supports_direct_io_with_exception_in_write|notifications\.objects\.test_objects\.TestObjectVersions\.test_versions|objects\.test_objects\.TestObjectVersions\.test_versions|notifications\.objects\.test_notification\.TestNotificationObjectVersions\.test_versions|db\.main\.test_api\.ArchiveTestCase\.test_archive_deleted_rows_task_log|db\.main\.test_api\.UnsupportedDbRegexpTestCase\.test_instance_get_all_by_filters_sort_keys))'
+	# Non-deterministic (see: https://bugs.launchpad.net/nova/+bug/2121125):
+	# nova.tests.unit.compute.test_compute.ComputeTestCase.test_add_remove_fixed_ip_updates_instance_updated_at
+	pkgos-dh_auto_test --no-py2 'nova\.tests\.unit\.(?!(.*virt.libvirt\.test_driver\.LibvirtConnTestCase\.test_spawn_with_config_drive.*|.*test_wsgi\.TestWSGIServerWithSSL.*|.*test_hacking\.HackingTestCase.*|.*CreateInstanceTypeTest\.test_name_with_non_printable_characters.*|.*PatternPropertiesTestCase\.test_validate_patternProperties_fails.*|.*virt\.libvirt\.test_driver\.LibvirtDriverTestCase\.test_get_disk_xml.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_detach_volume_with_vir_domain_affect_live_flag.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_update_volume_xml.*|.*console\.test_websocketproxy\.NovaProxyRequestHandlerTestCase\.test_tcp_rst_no_compute_rpcapi.*|.*virt\.libvirt\.test_blockinfo\.LibvirtBlockInfoTest\.test_get_disk_mapping_rescue_with_config.*|.*virt\.libvirt\.test_blockinfo\.LibvirtBlockInfoTest\.test_get_disk_mapping_stable_rescue_ide_cdrom.*|.*virt\.libvirt\.volume\.test_nvme\.LibvirtNVMEVolumeDriverTestCase\.test_libvirt_nvme_driver_connect.*|.*virt\.libvirt\.volume\.test_nvme\.LibvirtNVMEVolumeDriverTestCase\.test_libvirt_nvme_driver_disconnect.*|.*virt\.libvirt\.volume\.test_nvme\.LibvirtNVMEVolumeDriverTestCase\.test_libvirt_nvme_driver_get_config.*|.*virt\.libvirt\.volume\.test_scaleio\.LibvirtScaleIOVolumeDriverTestCase.*|.*virt\.libvirt\.test_driver\.LibvirtDriverTestCase\.test_cross_cell_move_rbd_flatten_fetch_image_cache.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_check_discard_for_attach_volume_blk_controller_no_unmap.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_check_discard_for_attach_volume_no_unmap.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_check_discard_for_attach_volume_valid_controller.*|.*virt\.libvirt\.test_driver\.LibvirtDriverTestCase\.test_rbd_image_flatten_during_fetch_image_cache.*|.*test_utils\.GenericUtilsTestCase\.test_temporary_chown.*|console\.test_websocketproxy\.NovaProxyRequestHandlerTestCase\.test_reject_open_redirect|console\.test_websocketproxy\.NovaProxyRequestHandlerTestCase\.test_reject_open_redirect_3_slashes|privsep\.test_utils\.SupportDirectIOTestCase\.test_supports_direct_io_with_exception_in_open|privsep\.test_utils\.SupportDirectIOTestCase\.test_supports_direct_io_with_exception_in_write|notifications\.objects\.test_objects\.TestObjectVersions\.test_versions|objects\.test_objects\.TestObjectVersions\.test_versions|notifications\.objects\.test_notification\.TestNotificationObjectVersions\.test_versions|db\.main\.test_api\.ArchiveTestCase\.test_archive_deleted_rows_task_log|db\.main\.test_api\.UnsupportedDbRegexpTestCase\.test_instance_get_all_by_filters_sort_keys|nova\.tests\.unit\.compute\.test_compute\.ComputeTestCase\.test_add_remove_fixed_ip_updates_instance_updated_at))'
 endif
 
 	rm -rf $(CURDIR)/debian/tmp/usr/etc
diff -Nru nova-31.0.0/debian/tests/unittests nova-31.0.0/debian/tests/unittests
--- nova-31.0.0/debian/tests/unittests	2025-07-12 11:35:02.000000000 +0200
+++ nova-31.0.0/debian/tests/unittests	2025-08-21 09:10:49.000000000 +0200
@@ -2,4 +2,4 @@
 
 set -e
 
-pkgos-dh_auto_test --no-py2 'nova\.tests\.unit\.(?!(.*virt.libvirt\.test_driver\.LibvirtConnTestCase\.test_spawn_with_config_drive.*|.*test_wsgi\.TestWSGIServerWithSSL.*|.*test_hacking\.HackingTestCase.*|.*CreateInstanceTypeTest\.test_name_with_non_printable_characters.*|.*PatternPropertiesTestCase\.test_validate_patternProperties_fails.*|.*virt\.libvirt\.test_driver\.LibvirtDriverTestCase\.test_get_disk_xml.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_detach_volume_with_vir_domain_affect_live_flag.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_update_volume_xml.*|.*console\.test_websocketproxy\.NovaProxyRequestHandlerTestCase\.test_tcp_rst_no_compute_rpcapi.*|.*virt\.libvirt\.test_blockinfo\.LibvirtBlockInfoTest\.test_get_disk_mapping_rescue_with_config.*|.*virt\.libvirt\.test_blockinfo\.LibvirtBlockInfoTest\.test_get_disk_mapping_stable_rescue_ide_cdrom.*|.*virt\.libvirt\.volume\.test_nvme\.LibvirtNVMEVolumeDriverTestCase\.test_libvirt_nvme_driver_connect.*|.*virt\.libvirt\.volume\.test_nvme\.LibvirtNVMEVolumeDriverTestCase\.test_libvirt_nvme_driver_disconnect.*|.*virt\.libvirt\.volume\.test_nvme\.LibvirtNVMEVolumeDriverTestCase\.test_libvirt_nvme_driver_get_config.*|.*virt\.libvirt\.volume\.test_scaleio\.LibvirtScaleIOVolumeDriverTestCase.*|.*virt\.libvirt\.test_driver\.LibvirtDriverTestCase\.test_cross_cell_move_rbd_flatten_fetch_image_cache.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_check_discard_for_attach_volume_blk_controller_no_unmap.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_check_discard_for_attach_volume_no_unmap.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_check_discard_for_attach_volume_valid_controller.*|.*virt\.libvirt\.test_driver\.LibvirtDriverTestCase\.test_rbd_image_flatten_during_fetch_image_cache.*|.*test_utils\.GenericUtilsTestCase\.test_temporary_chown.*|.*virt\.libvirt\.volume\.test_vzstorage\.LibvirtVZStorageTestCase.*|.*compute\.test_virtapi\.ComputeVirtAPITest\.test_wait_for_instance_event_one_received_one_timed_out.*|.*virt\.libvirt\.volume\.test_iser\.LibvirtISERVolumeDriverTestCase\.test_get_transport.*|console\.test_websocketproxy\.NovaProxyRequestHandlerTestCase\.test_reject_open_redirect|console\.test_websocketproxy\.NovaProxyRequestHandlerTestCase\.test_reject_open_redirect_3_slashes|privsep\.test_utils\.SupportDirectIOTestCase\.test_supports_direct_io_with_exception_in_open|privsep\.test_utils\.SupportDirectIOTestCase\.test_supports_direct_io_with_exception_in_write|notifications\.objects\.test_objects\.TestObjectVersions\.test_versions|objects\.test_objects\.TestObjectVersions\.test_versions|notifications\.objects\.test_notification\.TestNotificationObjectVersions\.test_versions|db\.main\.test_api\.ArchiveTestCase\.test_archive_deleted_rows_task_log|db\.main\.test_api\.UnsupportedDbRegexpTestCase\.test_instance_get_all_by_filters_sort_keys))'
+pkgos-dh_auto_test --no-py2 'nova\.tests\.unit\.(?!(.*virt.libvirt\.test_driver\.LibvirtConnTestCase\.test_spawn_with_config_drive.*|.*test_wsgi\.TestWSGIServerWithSSL.*|.*test_hacking\.HackingTestCase.*|.*CreateInstanceTypeTest\.test_name_with_non_printable_characters.*|.*PatternPropertiesTestCase\.test_validate_patternProperties_fails.*|.*virt\.libvirt\.test_driver\.LibvirtDriverTestCase\.test_get_disk_xml.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_detach_volume_with_vir_domain_affect_live_flag.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_update_volume_xml.*|.*console\.test_websocketproxy\.NovaProxyRequestHandlerTestCase\.test_tcp_rst_no_compute_rpcapi.*|.*virt\.libvirt\.test_blockinfo\.LibvirtBlockInfoTest\.test_get_disk_mapping_rescue_with_config.*|.*virt\.libvirt\.test_blockinfo\.LibvirtBlockInfoTest\.test_get_disk_mapping_stable_rescue_ide_cdrom.*|.*virt\.libvirt\.volume\.test_nvme\.LibvirtNVMEVolumeDriverTestCase\.test_libvirt_nvme_driver_connect.*|.*virt\.libvirt\.volume\.test_nvme\.LibvirtNVMEVolumeDriverTestCase\.test_libvirt_nvme_driver_disconnect.*|.*virt\.libvirt\.volume\.test_nvme\.LibvirtNVMEVolumeDriverTestCase\.test_libvirt_nvme_driver_get_config.*|.*virt\.libvirt\.volume\.test_scaleio\.LibvirtScaleIOVolumeDriverTestCase.*|.*virt\.libvirt\.test_driver\.LibvirtDriverTestCase\.test_cross_cell_move_rbd_flatten_fetch_image_cache.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_check_discard_for_attach_volume_blk_controller_no_unmap.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_check_discard_for_attach_volume_no_unmap.*|.*virt\.libvirt\.test_driver\.LibvirtConnTestCase\.test_check_discard_for_attach_volume_valid_controller.*|.*virt\.libvirt\.test_driver\.LibvirtDriverTestCase\.test_rbd_image_flatten_during_fetch_image_cache.*|.*test_utils\.GenericUtilsTestCase\.test_temporary_chown.*|.*virt\.libvirt\.volume\.test_vzstorage\.LibvirtVZStorageTestCase.*|.*compute\.test_virtapi\.ComputeVirtAPITest\.test_wait_for_instance_event_one_received_one_timed_out.*|.*virt\.libvirt\.volume\.test_iser\.LibvirtISERVolumeDriverTestCase\.test_get_transport.*|console\.test_websocketproxy\.NovaProxyRequestHandlerTestCase\.test_reject_open_redirect|console\.test_websocketproxy\.NovaProxyRequestHandlerTestCase\.test_reject_open_redirect_3_slashes|privsep\.test_utils\.SupportDirectIOTestCase\.test_supports_direct_io_with_exception_in_open|privsep\.test_utils\.SupportDirectIOTestCase\.test_supports_direct_io_with_exception_in_write|notifications\.objects\.test_objects\.TestObjectVersions\.test_versions|objects\.test_objects\.TestObjectVersions\.test_versions|notifications\.objects\.test_notification\.TestNotificationObjectVersions\.test_versions|db\.main\.test_api\.ArchiveTestCase\.test_archive_deleted_rows_task_log|db\.main\.test_api\.UnsupportedDbRegexpTestCase\.test_instance_get_all_by_filters_sort_keys|nova\.tests\.unit\.compute\.test_compute\.ComputeTestCase\.test_add_remove_fixed_ip_updates_instance_updated_at))'

Reply to: