Package: release.debian.org
Severity: normal
Tags: trixie
X-Debbugs-Cc: [email protected]
Control: affects -1 + src:manila
User: [email protected]
Usertags: pu

Hi,

I have uploaded manila 20.0.0-3+deb13u2 to Trixie p-u to address
2 issues:
- CVE-2026-71195
- a missing sudoers rule

[ Reason ]
CVE-2026-71195 / OSSA-2026-046: Manila allows cross-project
public-share and export-metadata changes.

The missing sudoers rule in debian/manila-common.sudoers:

+manila ALL = (root) NOPASSWD: /usr/bin/privsep-helper *

which was preventing Manila to issue root-needing commands
through rootwrap.

[ Impact ]
Many things not working due to missing sudoers rights, plus
the CVE.

[ Tests ]
The attached debdiff shows a lot of new tests ran at package
build and autopkgtest.

[ Risks ]
As this is tested in my CI, minimal risks.

[ 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 approve manila/20.0.0-3+deb13u2 into Trixie p-u.

Cheers,

Thomas Goirand (zigo)
diff -Nru manila-20.0.0/debian/changelog manila-20.0.0/debian/changelog
--- manila-20.0.0/debian/changelog      2026-08-20 17:26:40.000000000 +0200
+++ manila-20.0.0/debian/changelog      2026-10-15 23:15:31.000000000 +0200
@@ -1,3 +1,18 @@
+manila (1:20.0.0-3+deb13u2) trixie; urgency=medium
+
+  * Add /usr/bin/privsep-helper to the sudoers rules: manila now uses
+    oslo.privsep ("sudo privsep-helper") instead of rootwrap to run
+    privileged commands, and the share service runs as the manila user.
+    Without this, the LVM driver initialization fails with
+    FailedToDropPrivileges and manila-share never becomes available to
+    the scheduler.
+  * CVE-2026-71195 / OSSA-2026-046: Manila allows cross-project
+    public-share and export-metadata changes. Applied upstream patch
+    "Fix policy control for public shares" (Closes: #1151439):
+    - CVE-2026-71195_Fix_policy_control_for_public_shares.patch
+
+ -- Thomas Goirand <[email protected]>  Thu, 15 Oct 2026 23:15:31 +0200
+
 manila (1:20.0.0-3+deb13u1) trixie; urgency=medium
 
   * OSSN-0103: resource-lock list trusts a foreign project_id filter. Applied
diff -Nru manila-20.0.0/debian/manila-common.sudoers 
manila-20.0.0/debian/manila-common.sudoers
--- manila-20.0.0/debian/manila-common.sudoers  2026-08-20 17:26:40.000000000 
+0200
+++ manila-20.0.0/debian/manila-common.sudoers  2026-10-15 23:15:31.000000000 
+0200
@@ -1,3 +1,4 @@
 Defaults:manila !requiretty
 
 manila ALL = (root) NOPASSWD: /usr/bin/manila-rootwrap 
/etc/manila/rootwrap.conf *
+manila ALL = (root) NOPASSWD: /usr/bin/privsep-helper *
diff -Nru 
manila-20.0.0/debian/patches/CVE-2026-71195_Fix_policy_control_for_public_shares.patch
 
manila-20.0.0/debian/patches/CVE-2026-71195_Fix_policy_control_for_public_shares.patch
--- 
manila-20.0.0/debian/patches/CVE-2026-71195_Fix_policy_control_for_public_shares.patch
      1970-01-01 01:00:00.000000000 +0100
+++ 
manila-20.0.0/debian/patches/CVE-2026-71195_Fix_policy_control_for_public_shares.patch
      2026-10-15 23:15:31.000000000 +0200
@@ -0,0 +1,720 @@
+Description: Fix policy control for public shares
+ A public share is visible to all tenants, but users other than the owner
+ should not be able to modify it: several operations skipped the ownership
+ policy check for public shares. Enforce ownership checks when manipulating
+ access rules, creating backups or replicas, and soft deleting or restoring
+ public shares. Export location metadata operations are now scoped to the
+ share in the request URL; export locations that do not belong to that
+ share now return 404 (CVE-2026-71195).
+Origin: upstream, https://review.opendev.org/c/openstack/manila/+/1011483
+Change-Id: Ie283dce157f9c90f5d2ab38c1e74daa7df3ac118
+Bug: https://bugs.launchpad.net/bugs/2161412
+Bug: https://bugs.launchpad.net/bugs/2168962
+Bug-Debian: https://bugs.debian.org/1151439
+Author: Carlos da Silva <[email protected]>
+Date: Fri, 7 Aug 2026 20:55:09 -0300
+Last-Update: 2026-10-20
+
+
+Index: manila/manila/api/v1/shares.py
+===================================================================
+--- manila.orig/manila/api/v1/shares.py
++++ manila/manila/api/v1/shares.py
+@@ -552,7 +552,7 @@ class ShareMixin(object):
+         access_data = body.get('allow_access', body.get('os-allow_access'))
+         if not enable_metadata:
+             access_data.pop('metadata', None)
+-        share = self.share_api.get(context, id)
++        share = self.share_api.get(context, id, for_modification=True)
+ 
+         if share.get('is_soft_deleted'):
+             msg = _("Cannot allow access for share '%s' "
+@@ -666,7 +666,7 @@ class ShareMixin(object):
+ 
+         self._check_for_access_rule_locks(context, access_data, access_id, id)
+ 
+-        share = self.share_api.get(context, id)
++        share = self.share_api.get(context, id, for_modification=True)
+ 
+         if share.get('is_soft_deleted'):
+             msg = _("Cannot deny access for share '%s' "
+Index: manila/manila/api/v2/metadata.py
+===================================================================
+--- manila.orig/manila/api/v2/metadata.py
++++ manila/manila/api/v2/metadata.py
+@@ -92,7 +92,10 @@ class MetadataController(object):
+             get_res_method = getattr(
+                 db, self.resource_get[self.resource_name])
+             if parent_id is not None:
+-                kwargs["parent_id"] = parent_id
++                if self.resource_name == 'share_export_location':
++                    kwargs["share_id"] = parent_id
++                else:
++                    kwargs["parent_id"] = parent_id
+             res = get_res_method(context, resource_id, **kwargs)
+ 
+             if self.resource_name not in ["share_export_location"]:
+Index: manila/manila/api/v2/share_accesses.py
+===================================================================
+--- manila.orig/manila/api/v2/share_accesses.py
++++ manila/manila/api/v2/share_accesses.py
+@@ -168,7 +168,8 @@ class ShareAccessesController(wsgi.Contr
+         if access_level == share_access.access_level:
+             return self._view_builder.view(req, share_access)
+ 
+-        share = self.share_api.get(context, share_access.share_id)
++        share = self.share_api.get(
++            context, share_access.share_id, for_modification=True)
+         values = {
+             'access_level': access_level,
+         }
+Index: manila/manila/api/v2/share_backups.py
+===================================================================
+--- manila.orig/manila/api/v2/share_backups.py
++++ manila/manila/api/v2/share_backups.py
+@@ -158,7 +158,8 @@ class ShareBackupController(wsgi.Control
+             raise exc.HTTPBadRequest(explanation=msg)
+ 
+         try:
+-            share = self.share_api.get(context, share_id)
++            share = self.share_api.get(
++                context, share_id, for_modification=True)
+         except exception.NotFound:
+             msg = _("No share exists with ID %s.")
+             raise exc.HTTPBadRequest(explanation=msg % share_id)
+Index: manila/manila/api/v2/share_export_locations.py
+===================================================================
+--- manila.orig/manila/api/v2/share_export_locations.py
++++ manila/manila/api/v2/share_export_locations.py
+@@ -24,6 +24,7 @@ from manila.db import api as db_api
+ from manila import exception
+ from manila.i18n import _
+ from manila import policy
++from manila.share import api as share_api
+ 
+ LOG = log.getLogger(__name__)
+ CONF = cfg.CONF
+@@ -40,12 +41,13 @@ class ShareExportLocationController(wsgi
+         self._conf_admin_only_metadata_keys = getattr(
+             CONF, 'admin_only_el_metadata', []
+         )
++        self.share_api = share_api.API()
+ 
+-    def _verify_share(self, context, share_id):
++    def _verify_share(self, context, share_id, for_modification=False):
+         try:
+-            share = db_api.share_get(context, share_id)
+-            if not share['is_public']:
+-                policy.check_policy(context, 'share', 'get', share)
++            self.share_api.get(
++                context, share_id, for_modification=for_modification
++            )
+         except exception.NotFound:
+             msg = _("Share '%s' not found.") % share_id
+             raise exc.HTTPNotFound(explanation=msg)
+@@ -141,7 +143,7 @@ class ShareExportLocationController(wsgi
+         """Returns the list of metadata for a given share export location."""
+         context = req.environ['manila.context']
+         self._verify_share(context, share_id)
+-        return self._index_metadata(req, resource_id)
++        return self._index_metadata(req, resource_id, parent_id=share_id)
+ 
+     @wsgi.Controller.api_version("2.87")
+     @wsgi.Controller.authorize("update_metadata")
+@@ -152,8 +154,9 @@ class ShareExportLocationController(wsgi
+                                                        delete=False)
+         body['metadata'] = _metadata
+         context = req.environ['manila.context']
+-        self._verify_share(context, share_id)
+-        return self._create_metadata(req, resource_id, body)
++        self._verify_share(context, share_id, for_modification=True)
++        return self._create_metadata(
++            req, resource_id, body, parent_id=share_id)
+ 
+     @wsgi.Controller.api_version("2.87")
+     @wsgi.Controller.authorize("update_metadata")
+@@ -163,8 +166,9 @@ class ShareExportLocationController(wsgi
+                                                        body['metadata'])
+         body['metadata'] = _metadata
+         context = req.environ['manila.context']
+-        self._verify_share(context, share_id)
+-        return self._update_all_metadata(req, resource_id, body)
++        self._verify_share(context, share_id, for_modification=True)
++        return self._update_all_metadata(
++            req, resource_id, body, parent_id=share_id)
+ 
+     @wsgi.Controller.api_version("2.87")
+     @wsgi.Controller.authorize("update_metadata")
+@@ -175,8 +179,9 @@ class ShareExportLocationController(wsgi
+                                                        delete=False)
+         body['metadata'] = _metadata
+         context = req.environ['manila.context']
+-        self._verify_share(context, share_id)
+-        return self._update_metadata_item(req, resource_id, body, key)
++        self._verify_share(context, share_id, for_modification=True)
++        return self._update_metadata_item(
++            req, resource_id, body, key, parent_id=share_id)
+ 
+     @wsgi.Controller.api_version("2.87")
+     @wsgi.Controller.authorize("get_metadata")
+@@ -184,15 +189,17 @@ class ShareExportLocationController(wsgi
+         """Show metadata for a given share export location."""
+         context = req.environ['manila.context']
+         self._verify_share(context, share_id)
+-        return self._show_metadata(req, resource_id, key)
++        return self._show_metadata(
++            req, resource_id, key, parent_id=share_id)
+ 
+     @wsgi.Controller.api_version("2.87")
+     @wsgi.Controller.authorize("delete_metadata")
+     def delete_metadata(self, req, share_id, resource_id, key):
+         """Delete metadata for a given share export location."""
+         context = req.environ['manila.context']
+-        self._verify_share(context, share_id)
+-        return self._delete_metadata(req, resource_id, key)
++        self._verify_share(context, share_id, for_modification=True)
++        return self._delete_metadata(
++            req, resource_id, key, parent_id=share_id)
+ 
+ 
+ def create_resource():
+Index: manila/manila/api/v2/share_replicas.py
+===================================================================
+--- manila.orig/manila/api/v2/share_replicas.py
++++ manila/manila/api/v2/share_replicas.py
+@@ -166,7 +166,8 @@ class ShareReplicationController(wsgi.Co
+             raise exc.HTTPBadRequest(explanation=msg)
+ 
+         try:
+-            share_ref = db.share_get(context, share_id)
++            share_ref = self.share_api.get(
++                context, share_id, for_modification=True)
+         except exception.NotFound:
+             msg = _("No share exists with ID %s.")
+             raise exc.HTTPNotFound(explanation=msg % share_id)
+Index: manila/manila/api/v2/shares.py
+===================================================================
+--- manila.orig/manila/api/v2/shares.py
++++ manila/manila/api/v2/shares.py
+@@ -280,7 +280,7 @@ class ShareController(wsgi.Controller,
+         LOG.debug("Soft delete share with id: %s", id, context=context)
+ 
+         try:
+-            share = self.share_api.get(context, id)
++            share = self.share_api.get(context, id, for_modification=True)
+             self.share_api.soft_delete(context, share)
+         except exception.NotFound:
+             raise exc.HTTPNotFound()
+@@ -303,7 +303,7 @@ class ShareController(wsgi.Controller,
+         LOG.debug("Restore share with id: %s", id, context=context)
+ 
+         try:
+-            share = self.share_api.get(context, id)
++            share = self.share_api.get(context, id, for_modification=True)
+         except exception.NotFound:
+             msg = _("No share exists with ID %s.")
+             raise exc.HTTPNotFound(explanation=msg % id)
+Index: manila/manila/share/api.py
+===================================================================
+--- manila.orig/manila/share/api.py
++++ manila/manila/share/api.py
+@@ -2296,11 +2296,13 @@ class API(base.Base):
+     def snapshot_update(self, context, snapshot, fields):
+         return self.db.share_snapshot_update(context, snapshot['id'], fields)
+ 
+-    def get(self, context, share_id):
++    def get(self, context, share_id, for_modification=False):
+         share = self.db.share_get(context, share_id)
+-        if not share['is_public']:
++        share_is_public = share['is_public']
++        if not share_is_public or for_modification:
+             authorized = policy.check_policy(
+-                context, 'share', 'get', share, do_raise=False)
++                context, 'share', 'get', share,
++                do_raise=share_is_public and for_modification)
+             if not authorized:
+                 raise exception.NotFound()
+         if share['status'] in (
+Index: manila/manila/tests/api/v2/test_metadata.py
+===================================================================
+--- manila.orig/manila/tests/api/v2/test_metadata.py
++++ manila/manila/tests/api/v2/test_metadata.py
+@@ -17,6 +17,7 @@ import webob
+ 
+ from manila.api.v2 import metadata
+ from manila import context
++from manila import db
+ from manila import exception
+ from manila import policy
+ from manila import test
+@@ -81,6 +82,46 @@ class MetadataAPITest(test.TestCase):
+         policy_resource_obj = mock_policy_check.call_args[0][3]
+         self.assertEqual(share_obj['id'], policy_resource_obj['id'])
+ 
++    def test__get_resource_export_location_scoped_to_share(self):
++        # Export location lookups must be scoped to the share in the
++        # request URL, so that the metadata of an export location
++        # belonging to a different share cannot be retrieved.
++        self.controller.resource_name = 'share_export_location'
++        fake_context = context.RequestContext('fake', 'fake')
++        export_location = {'id': 'fake-el-uuid'}
++        mock_get_by_uuid = self.mock_object(
++            db, 'export_location_get_by_uuid',
++            mock.Mock(return_value=export_location))
++
++        res = self.controller._get_resource(
++            fake_context, 'fake-el-uuid', for_modification=True,
++            parent_id='fake-share-id')
++
++        self.assertEqual(export_location, res)
++        mock_get_by_uuid.assert_called_once_with(
++            fake_context, 'fake-el-uuid', share_id='fake-share-id')
++        self.mock_policy_check.assert_not_called()
++
++    def test__get_resource_export_location_not_in_share_not_found(self):
++        # An export location that does not belong to the share in the
++        # request URL must not be accessible, regardless of the type
++        # of the share it belongs to.
++        self.controller.resource_name = 'share_export_location'
++        fake_context = context.RequestContext('fake', 'fake')
++        self.mock_object(
++            db, 'export_location_get_by_uuid',
++            mock.Mock(side_effect=exception.ExportLocationNotFound(
++                uuid='fake-el-uuid')))
++
++        self.assertRaises(
++            webob.exc.HTTPNotFound,
++            self.controller._get_resource,
++            fake_context,
++            'fake-el-uuid',
++            for_modification=True,
++            parent_id='fake-share-id',
++        )
++
+     def test_create_index_metadata(self):
+         url = self._get_request()
+         body = {'metadata': {'test_key1': 'test_v1', 'test_key2': 'test_v2'}}
+Index: manila/manila/tests/api/v2/test_share_accesses.py
+===================================================================
+--- manila.orig/manila/tests/api/v2/test_share_accesses.py
++++ manila/manila/tests/api/v2/test_share_accesses.py
+@@ -15,7 +15,6 @@
+ 
+ from unittest import mock
+ 
+-import copy
+ import ddt
+ from webob import exc
+ 
+@@ -247,17 +246,55 @@ class ShareAccessesAPITest(test.TestCase
+         return req
+ 
+     def test_update_access_level(self):
+-        update_share_access = copy.deepcopy(self.access)
+-        update_share_access.update({'access_level': 'ro'})
+-        self.mock_object(
++        fake_access = mock.Mock(
++            access_level='rw', share_id=self.share['id'])
++        updated_access = mock.Mock(
++            access_level='ro', share_id=self.share['id'])
++        mock_get_access = self.mock_object(
++            self.controller, '_get_share_access',
++            mock.Mock(return_value=fake_access))
++        mock_get = self.mock_object(
++            self.controller.share_api, 'get',
++            mock.Mock(return_value=self.share))
++        mock_update = self.mock_object(
+             self.controller.share_api, 'update_access',
+-            mock.Mock(return_value=update_share_access))
++            mock.Mock(return_value=updated_access))
++        mock_view = self.mock_object(
++            self.controller._view_builder, 'view',
++            mock.Mock(return_value={'access': {'access_level': 'ro'}}))
++
++        body = {'update_access': {'access_level': 'ro'}}
++        req = self._get_update_request()
++        context = req.environ['manila.context']
++        ret = self.controller.update(req, self.access['id'], body=body)
++
++        self.assertEqual('ro', ret['access']['access_level'])
++        mock_get_access.assert_called_once_with(
++            context, self.access['id'])
++        mock_get.assert_called_once_with(
++            context, self.share['id'], for_modification=True)
++        mock_update.assert_called_once_with(
++            context, self.share, fake_access, {'access_level': 'ro'})
++        mock_view.assert_called_once_with(req, updated_access)
++
++    def test_update_policy_not_authorized(self):
++        fake_access = mock.Mock(
++            access_level='rw', share_id=self.share['id'])
++        self.mock_object(
++            self.controller, '_get_share_access',
++            mock.Mock(return_value=fake_access))
++        self.mock_object(
++            self.controller.share_api, 'get',
++            mock.Mock(side_effect=exception.PolicyNotAuthorized(
++                action='share:get')))
+ 
+         body = {'update_access': {'access_level': 'ro'}}
+-        url = self._get_update_request()
+-        ret = self.controller.update(url, self.access['id'], body=body)
+-        self.assertEqual(update_share_access['access_level'],
+-                         ret['access']['access_level'])
++        req = self._get_update_request()
++
++        self.assertRaises(
++            exception.PolicyNotAuthorized,
++            self.controller.update,
++            req, self.access['id'], body=body)
+ 
+     def test_update_access_level_invalid_access_level(self):
+         body = {'access': {'access_level': 'fake_access'}}
+Index: manila/manila/tests/api/v2/test_share_backups.py
+===================================================================
+--- manila.orig/manila/tests/api/v2/test_share_backups.py
++++ manila/manila/tests/api/v2/test_share_backups.py
+@@ -362,19 +362,38 @@ class ShareBackupsApiTest(test.TestCase)
+ 
+     def test_create(self):
+         fake_backup, expected_backup = self._get_fake_backup()
++        share_id = 'FAKE_SHAREID'
+         body = {
+             'share_backup': {
+-                'share_id': 'FAKE_SHAREID',
++                'share_id': share_id,
+             }
+         }
+-        self.mock_object(share.API, 'get',
+-                         mock.Mock(return_value={'id': 'FAKE_SHAREID'}))
++        mock_get = self.mock_object(
++            share.API, 'get', mock.Mock(return_value={'id': share_id}))
+         self.mock_object(share.API, 'create_share_backup',
+                          mock.Mock(return_value=fake_backup))
+ 
+         req = self.backups_req
+         res_dict = self.controller.create(req, body)
+         self.assertEqual(expected_backup, res_dict['share_backup'])
++        mock_get.assert_called_once_with(
++            self.backups_req.environ['manila.context'], share_id,
++            for_modification=True)
++
++    def test_create_policy_not_authorized(self):
++        body = {
++            'share_backup': {
++                'share_id': 'FAKE_SHAREID',
++            }
++        }
++        self.mock_object(
++            share.API, 'get',
++            mock.Mock(side_effect=exception.PolicyNotAuthorized(
++                action='share:get')))
++
++        self.assertRaises(exception.PolicyNotAuthorized,
++                          self.controller.create,
++                          self.backups_req, body)
+ 
+     def test_delete_invalid_backup(self):
+         fake_exception = exception.ShareBackupNotFound(
+Index: manila/manila/tests/api/v2/test_share_export_locations.py
+===================================================================
+--- manila.orig/manila/tests/api/v2/test_share_export_locations.py
++++ manila/manila/tests/api/v2/test_share_export_locations.py
+@@ -307,7 +307,8 @@ class ShareExportLocationsAPITest(test.T
+         self.assertEqual(body, res)
+         mock_validate.assert_called_once_with(req, el_id, body['metadata'],
+                                               delete=False)
+-        mock_create.assert_called_once_with(req, el_id, body)
++        mock_create.assert_called_once_with(
++            req, el_id, body, parent_id=self.share['id'])
+ 
+     def test_update_all_metadata(self):
+         index_result = self.controller.index(self.req, self.share['id'])
+@@ -329,7 +330,34 @@ class ShareExportLocationsAPITest(test.T
+                                                   body)
+         self.assertEqual(body, res)
+         mock_validate.assert_called_once_with(req, el_id, body['metadata'])
+-        mock_update.assert_called_once_with(req, el_id, body)
++        mock_update.assert_called_once_with(
++            req, el_id, body, parent_id=self.share['id'])
++
++    def test__verify_share_public_share_skips_policy_for_read(self):
++        public_share = db_utils.create_share(is_public=True)
++
++        self.controller._verify_share(
++            self.ctxt['user'], public_share['id'])
++
++        self.mock_policy_check.assert_not_called()
++
++    def 
test__verify_share_public_share_enforces_policy_for_modification(self):
++        public_share = db_utils.create_share(is_public=True)
++
++        self.controller._verify_share(
++            self.ctxt['user'], public_share['id'], for_modification=True)
++
++        self.mock_policy_check.assert_called_once_with(
++            self.ctxt['user'], 'share', 'get', mock.ANY, do_raise=True)
++
++    def test__verify_share_private_share_always_enforces_policy(self):
++        private_share = db_utils.create_share(is_public=False)
++
++        self.controller._verify_share(
++            self.ctxt['user'], private_share['id'])
++
++        self.mock_policy_check.assert_called_once_with(
++            self.ctxt['user'], 'share', 'get', mock.ANY, do_raise=False)
+ 
+     def test_delete_metadata(self):
+         index_result = self.controller.index(self.req, self.share['id'])
+@@ -343,4 +371,5 @@ class ShareExportLocationsAPITest(test.T
+             version="2.87", use_admin_context=True)
+         self.controller.delete_metadata(req, self.share['id'], el_id,
+                                         'fake_key')
+-        mock_delete.assert_called_once_with(req, el_id, 'fake_key')
++        mock_delete.assert_called_once_with(
++            req, el_id, 'fake_key', parent_id=self.share['id'])
+Index: manila/manila/tests/api/v2/test_share_replicas.py
+===================================================================
+--- manila.orig/manila/tests/api/v2/test_share_replicas.py
++++ manila/manila/tests/api/v2/test_share_replicas.py
+@@ -378,6 +378,43 @@ class ShareReplicasApiTest(test.TestCase
+         self.mock_policy_check.assert_called_once_with(
+             self.member_context, self.resource_name, 'create')
+ 
++    def test_create_share_from_other_project_not_found(self):
++        # A share that belongs to another project is not visible to the
++        # caller, so the ownership lookup must fail with 404.
++        body = {
++            'share_replica': {
++                'share_id': 'FAKE_SHAREID',
++                'availability_zone': 'FAKE_AZ'
++            }
++        }
++        self.mock_object(share.API, 'get',
++                         mock.Mock(side_effect=exception.NotFound))
++
++        self.assertRaises(exc.HTTPNotFound,
++                          self.controller.create,
++                          self.replicas_req, body)
++        self.mock_policy_check.assert_called_once_with(
++            self.member_context, self.resource_name, 'create')
++
++    def test_create_share_from_other_project_not_authorized(self):
++        # A public share that belongs to another project: the ownership
++        # policy check inside the share lookup must raise
++        # PolicyNotAuthorized, which the WSGI layer converts to 403.
++        body = {
++            'share_replica': {
++                'share_id': 'FAKE_SHAREID',
++                'availability_zone': 'FAKE_AZ'
++            }
++        }
++        self.mock_object(share.API, 'get', mock.Mock(
++            side_effect=exception.PolicyNotAuthorized(action='share:get')))
++
++        self.assertRaises(exception.PolicyNotAuthorized,
++                          self.controller.create,
++                          self.replicas_req, body)
++        self.mock_policy_check.assert_called_once_with(
++            self.member_context, self.resource_name, 'create')
++
+     def test_create_has_been_soft_deleted(self):
+         share_ref = fake_share.fake_share(is_soft_deleted=True)
+         body = {
+@@ -389,7 +426,7 @@ class ShareReplicasApiTest(test.TestCase
+         mock__view_builder_call = self.mock_object(
+             share_replicas.replication_view.ReplicationViewBuilder,
+             'detail_list')
+-        self.mock_object(share_replicas.db, 'share_get',
++        self.mock_object(share.API, 'get',
+                          mock.Mock(return_value=share_ref))
+ 
+         self.assertRaises(exc.HTTPForbidden,
+@@ -415,7 +452,7 @@ class ShareReplicasApiTest(test.TestCase
+         mock__view_builder_call = self.mock_object(
+             share_replicas.replication_view.ReplicationViewBuilder,
+             'detail_list')
+-        self.mock_object(share_replicas.db, 'share_get',
++        self.mock_object(share.API, 'get',
+                          mock.Mock(return_value=fake_replica))
+         self.mock_object(share_replicas.db, 'share_network_get',
+                          mock.Mock(side_effect=exception.ShareNetworkNotFound(
+@@ -444,7 +481,7 @@ class ShareReplicasApiTest(test.TestCase
+             }
+         }
+         exc_args = {'id': 'xyz', 'reason': 'abc'}
+-        self.mock_object(share_replicas.db, 'share_get',
++        self.mock_object(share.API, 'get',
+                          mock.Mock(return_value=fake_replica))
+         self.mock_object(share.API, 'create_share_replica',
+                          mock.Mock(side_effect=exception_type(**exc_args)))
+@@ -483,7 +520,7 @@ class ShareReplicasApiTest(test.TestCase
+         else:
+             share_network = db_utils.create_share_network()
+ 
+-        self.mock_object(share_replicas.db, 'share_get',
++        self.mock_object(share.API, 'get',
+                          mock.Mock(return_value=fake_replica))
+         self.mock_object(share.API, 'create_share_replica',
+                          mock.Mock(return_value=fake_replica))
+Index: manila/manila/tests/api/v2/test_shares.py
+===================================================================
+--- manila.orig/manila/tests/api/v2/test_shares.py
++++ manila/manila/tests/api/v2/test_shares.py
+@@ -1627,8 +1627,14 @@ class ShareAPITest(test.TestCase):
+         req = fakes.HTTPRequest.blank('/v2/fake/shares/1/action',
+                                       version='2.69')
+         body = {"soft_delete": None}
+-        resp = self.controller.share_soft_delete(req, 1, body)
++        mock_get = self.mock_object(
++            share_api.API, 'get', mock.Mock(return_value=self.share))
++
++        resp = self.controller.share_soft_delete(req, 1, body=body)
++
+         self.assertEqual(202, resp.status_int)
++        mock_get.assert_called_once_with(
++            req.environ['manila.context'], 1, for_modification=True)
+ 
+     def test_share_soft_delete_has_been_soft_deleted_already(self):
+         req = fakes.HTTPRequest.blank('/v2/fake/shares/1/action',
+@@ -1661,10 +1667,15 @@ class ShareAPITest(test.TestCase):
+         req = fakes.HTTPRequest.blank('/v2/fake/shares/1/action',
+                                       version='2.69')
+         body = {"restore": None}
+-        self.mock_object(share_api.API, 'get',
+-                         mock.Mock(return_value=self.share_in_recycle_bin))
+-        resp = self.controller.share_restore(req, 1, body)
++        mock_get = self.mock_object(
++            share_api.API, 'get',
++            mock.Mock(return_value=self.share_in_recycle_bin))
++
++        resp = self.controller.share_restore(req, 1, body=body)
++
+         self.assertEqual(202, resp.status_int)
++        mock_get.assert_called_once_with(
++            req.environ['manila.context'], 1, for_modification=True)
+ 
+     def test_share_restore_with_deleting_status(self):
+         req = fakes.HTTPRequest.blank('/v2/fake/shares/1/action',
+@@ -1675,7 +1686,35 @@ class ShareAPITest(test.TestCase):
+             mock.Mock(return_value=self.share_in_recycle_bin_is_deleting))
+         self.assertRaises(
+             webob.exc.HTTPForbidden, self.controller.share_restore,
+-            req, 1, body)
++            req, 1, body=body)
++
++    def test_share_soft_delete_policy_not_authorized(self):
++        req = fakes.HTTPRequest.blank('/v2/fake/shares/1/action',
++                                      version='2.69')
++        body = {"soft_delete": None}
++        self.mock_object(
++            share_api.API, 'get',
++            mock.Mock(side_effect=exception.PolicyNotAuthorized(
++                action='share:get')))
++
++        self.assertRaises(
++            exception.PolicyNotAuthorized,
++            self.controller.share_soft_delete,
++            req, 1, body=body)
++
++    def test_share_restore_policy_not_authorized(self):
++        req = fakes.HTTPRequest.blank('/v2/fake/shares/1/action',
++                                      version='2.69')
++        body = {"restore": None}
++        self.mock_object(
++            share_api.API, 'get',
++            mock.Mock(side_effect=exception.PolicyNotAuthorized(
++                action='share:get')))
++
++        self.assertRaises(
++            exception.PolicyNotAuthorized,
++            self.controller.share_restore,
++            req, 1, body=body)
+ 
+     def test_share_delete(self):
+         req = fakes.HTTPRequest.blank('/v2/fake/shares/1')
+Index: manila/manila/tests/share/test_api.py
+===================================================================
+--- manila.orig/manila/tests/share/test_api.py
++++ manila/manila/tests/share/test_api.py
+@@ -3098,6 +3098,64 @@ class ShareAPITestCase(test.TestCase):
+                 ctx, 'share', 'get', share, do_raise=False)
+             db_api.share_get.assert_called_once_with(ctx, share['id'])
+ 
++    def test_get_public_share_skips_policy_check(self):
++        fake_share = db_utils.create_share(is_public=True)
++        self.mock_object(db_api, 'share_get',
++                         mock.Mock(return_value=fake_share))
++
++        result = self.api.get(self.context, 'fakeid')
++
++        self.assertEqual(fake_share, result)
++        share_api.policy.check_policy.assert_not_called()
++
++    def test_get_public_share_for_modification_checks_policy(self):
++        fake_share = db_utils.create_share(is_public=True)
++        share_api.policy.check_policy.return_value = True
++        self.mock_object(db_api, 'share_get',
++                         mock.Mock(return_value=fake_share))
++
++        result = self.api.get(
++            self.context, 'fakeid', for_modification=True)
++
++        self.assertEqual(fake_share, result)
++        share_api.policy.check_policy.assert_called_once_with(
++            self.context, 'share', 'get', fake_share, do_raise=True)
++
++    def test_get_public_share_for_modification_not_authorized(self):
++        fake_share = db_utils.create_share(
++            is_public=True,
++            project_id='df6d65cc1f8946ba86be06b8140ec4b3')
++        self.mock_object(
++            share_api.policy, 'check_policy',
++            mock.Mock(side_effect=exception.PolicyNotAuthorized(
++                action='share:get')))
++        self.mock_object(db_api, 'share_get',
++                         mock.Mock(return_value=fake_share))
++        ctx = context.RequestContext('fake_uid', 'fake_pid', is_admin=False)
++
++        self.assertRaises(exception.PolicyNotAuthorized,
++                          self.api.get,
++                          ctx,
++                          fake_share['id'],
++                          for_modification=True)
++
++    def test_get_private_share_for_modification_not_authorized(self):
++        fake_share = db_utils.create_share(
++            is_public=False,
++            project_id='df6d65cc1f8946ba86be06b8140ec4b3')
++        share_api.policy.check_policy.return_value = False
++        self.mock_object(db_api, 'share_get',
++                         mock.Mock(return_value=fake_share))
++        ctx = context.RequestContext('fake_uid', 'fake_pid', is_admin=False)
++
++        self.assertRaises(exception.NotFound,
++                          self.api.get,
++                          ctx,
++                          fake_share['id'],
++                          for_modification=True)
++        share_api.policy.check_policy.assert_called_once_with(
++            ctx, 'share', 'get', fake_share, do_raise=False)
++
+     @mock.patch.object(db_api, 'share_snapshot_get_all_by_project',
+                        mock.Mock())
+     def test_get_all_snapshots_admin_not_all_tenants(self):
+Index: 
manila/releasenotes/notes/bug-2161412-enforce-policy-checks-on-public-shares-6cb60f669bb20f6c.yaml
+===================================================================
+--- /dev/null
++++ 
manila/releasenotes/notes/bug-2161412-enforce-policy-checks-on-public-shares-6cb60f669bb20f6c.yaml
+@@ -0,0 +1,11 @@
++---
++security:
++  - |
++    Any user was able to manipulate the access rules, create backups or
++    replicas, soft delete or restore public shares they did not own,
++    and read or manipulate the export location metadata of shares they
++    did not own. These operations now enforce ownership, and export
++    location metadata is scoped to the share in the request URL. For
++    details, see `Launchpad bug #2161412
++    <https://bugs.launchpad.net/manila/+bug/2161412>`_ and `Launchpad
++    bug #2168962 <https://bugs.launchpad.net/manila/+bug/2168962>`_.
diff -Nru manila-20.0.0/debian/patches/series 
manila-20.0.0/debian/patches/series
--- manila-20.0.0/debian/patches/series 2026-08-20 17:26:40.000000000 +0200
+++ manila-20.0.0/debian/patches/series 2026-10-15 23:15:31.000000000 +0200
@@ -1,2 +1,3 @@
 install-missing-files.patch
 OSSN-0103_Prevent_unauthorized_resource_locks_search.patch
+CVE-2026-71195_Fix_policy_control_for_public_shares.patch

Reply via email to