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

Hi,

[ Reason ]
I'd like to upload Glance u3 to Trixie to address:
https://wiki.openstack.org/wiki/OSSN/OSSN-0105
aka:
https://bugs.debian.org/1144212

The security team asked me to go through p-u.

[ Impact ]
As per upstream announce, Glance is otherwise affected by
"legacy Tasks import bypasses image import URI"

[ Tests ]
The patch includes new tests, ran at package build time.

[ Risks ]
Not much, the patch is small (if excluding tests).

[ 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 glance/2:30.0.0-3+deb13u3 to
trixie-pu.

Cheers,

Thomas Goirand (zigo)
diff -Nru glance-30.0.0/debian/changelog glance-30.0.0/debian/changelog
--- glance-30.0.0/debian/changelog      2026-04-27 08:23:24.000000000 +0200
+++ glance-30.0.0/debian/changelog      2026-08-20 14:02:16.000000000 +0200
@@ -1,3 +1,11 @@
+glance (2:30.0.0-3+deb13u3) trixie; urgency=medium
+
+  * OSSN-0105: legacy Tasks import bypasses image import URI filtering.
+    Applied upstream patch: "Apply import URI filtering to legacy import
+    tasks" (Closes: #1144212).
+
+ -- Thomas Goirand <[email protected]>  Thu, 20 Aug 2026 14:02:16 +0200
+
 glance (2:30.0.0-3+deb13u2) trixie; urgency=medium
 
   * Add No_DNS_resolution_in_test.patch, otherwise Glance cannot be build in
diff -Nru 
glance-30.0.0/debian/patches/OSSN-0105_Apply_import_URI_filtering_to_legacy_import_tasks.patch
 
glance-30.0.0/debian/patches/OSSN-0105_Apply_import_URI_filtering_to_legacy_import_tasks.patch
--- 
glance-30.0.0/debian/patches/OSSN-0105_Apply_import_URI_filtering_to_legacy_import_tasks.patch
      1970-01-01 01:00:00.000000000 +0100
+++ 
glance-30.0.0/debian/patches/OSSN-0105_Apply_import_URI_filtering_to_legacy_import_tasks.patch
      2026-08-20 14:02:16.000000000 +0200
@@ -0,0 +1,436 @@
+Description: Apply import URI filtering to legacy import tasks
+ Legacy type=import tasks only ran validate_location_uri, so
+ import_from could skip the same host/port/path checks used for
+ modern image import. Route import_from through a small helper that
+ reuses validate_location_uri then validate_import_uri, and
+ reject bad URIs early when creating import tasks.
+Author: Abhishek Kekane <[email protected]>
+Date: Tue, 12 May 2026 06:53:53 +0000
+Depends-On: https://review.opendev.org/c/openstack/glance/+/996220
+Bug: https://launchpad.net/bugs/2152110
+Bug-Debian: https://bugs.debian.org/1144212
+Assisted-By: Cursor (claude-4.5-sonnet) for tests
+Change-Id: I6fbb8a91a49e3df6fa9bb185f7fcd8b1816ad74e
+Signed-off-by: Abhishek Kekane <[email protected]>
+Origin: upstream, https://review.opendev.org/c/openstack/glance/+/1000061
+Last-Update: 2026-08-20
+
+diff --git a/glance/api/v2/tasks.py b/glance/api/v2/tasks.py
+index ad12586..61cd0a9 100644
+--- a/glance/api/v2/tasks.py
++++ b/glance/api/v2/tasks.py
+@@ -16,6 +16,7 @@
+ 
+ import copy
+ import http.client as http
++import urllib.error
+ import urllib.parse as urlparse
+ 
+ import debtcollector
+@@ -31,6 +32,7 @@
+ from glance.api import policy
+ from glance.api.v2 import policy as api_policy
+ from glance.common import exception
++from glance.common.scripts import utils as script_utils
+ from glance.common import timeutils
+ from glance.common import wsgi
+ import glance.db
+@@ -74,6 +76,10 @@
+         executor_factory = self.gateway.get_task_executor_factory(ctxt)
+         task_repo = self.gateway.get_task_repo(ctxt)
+         try:
++            if task.get('type') == 'import':
++                task_input = task.get('input') or {}
++                script_utils.validate_legacy_import_from_uri(
++                    task_input.get('import_from'))
+             new_task = task_factory.new_task(
+                 task_type=task['type'],
+                 owner=ctxt.owner,
+@@ -90,6 +96,12 @@
+                    % {'reason': encodeutils.exception_to_unicode(e)})
+             LOG.warning(msg)
+             raise webob.exc.HTTPForbidden(explanation=e.msg)
++        except exception.BadStoreUri as e:
++            raise webob.exc.HTTPBadRequest(explanation=e.msg)
++        except exception.Invalid as e:
++            raise webob.exc.HTTPBadRequest(explanation=e.msg)
++        except urllib.error.URLError as e:
++            raise webob.exc.HTTPBadRequest(explanation=str(e.reason))
+         return new_task
+ 
+     @debtcollector.removals.remove(message=_DEPRECATION_MESSAGE)
+diff --git a/glance/async_/taskflow_executor.py 
b/glance/async_/taskflow_executor.py
+index b648639..87a3568 100644
+--- a/glance/async_/taskflow_executor.py
++++ b/glance/async_/taskflow_executor.py
+@@ -125,7 +125,7 @@
+                 kwds['admin_repo'] = self.admin_repo
+ 
+             if task.type == "import":
+-                uri = script_utils.validate_location_uri(
++                uri = script_utils.validate_legacy_import_from_uri(
+                     task_input.get('import_from'))
+                 kwds['uri'] = uri
+             if task.type == 'api_image_import':
+diff --git a/glance/common/scripts/image_import/main.py 
b/glance/common/scripts/image_import/main.py
+index cde2bf3..4cfdb64 100644
+--- a/glance/common/scripts/image_import/main.py
++++ b/glance/common/scripts/image_import/main.py
+@@ -53,7 +53,8 @@
+     try:
+         task_input = script_utils.unpack_task_input(task)
+ 
+-        uri = 
script_utils.validate_location_uri(task_input.get('import_from'))
++        uri = script_utils.validate_legacy_import_from_uri(
++            task_input.get('import_from'))
+         image_id = import_image(image_repo, image_factory, task_input, t_id,
+                                 uri)
+ 
+diff --git a/glance/common/scripts/utils.py b/glance/common/scripts/utils.py
+index aa6354d..74b088c 100644
+--- a/glance/common/scripts/utils.py
++++ b/glance/common/scripts/utils.py
+@@ -18,6 +18,7 @@
+     'unpack_task_input',
+     'set_base_image_properties',
+     'validate_location_uri',
++    'validate_legacy_import_from_uri',
+     'get_image_data_iter',
+     'SafeRedirectHandler',
+ ]
+@@ -129,6 +130,16 @@
+         raise urllib.error.URLError(msg)
+ 
+ 
++def validate_legacy_import_from_uri(location):
++    """Validate legacy ``import_from`` URI (scheme + import filter)."""
++    uri = validate_location_uri(location)
++    if not common_utils.validate_import_uri(uri):
++        msg = (_("URI for legacy import task does not pass filtering: %s") %
++               uri)
++        raise exception.Invalid(msg)
++    return uri
++
++
+ class SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
+     """HTTP redirect handler that validates redirect destinations."""
+     def redirect_request(self, req, fp, code, msg, headers, newurl):
+diff --git a/glance/tests/unit/async_/flows/plugins/test_image_conversion.py 
b/glance/tests/unit/async_/flows/plugins/test_image_conversion.py
+index ada82b5..bd047ca 100644
+--- a/glance/tests/unit/async_/flows/plugins/test_image_conversion.py
++++ b/glance/tests/unit/async_/flows/plugins/test_image_conversion.py
+@@ -70,7 +70,7 @@
+                                                 container_format='bare')
+ 
+         task_input = {
+-            "import_from": "http://cloud.foo/image.raw";,
++            "import_from": "http://198.51.100.1/image.raw";,
+             "import_from_format": "raw",
+             "image_properties": {'disk_format': 'raw',
+                                  'container_format': 'bare'}
+diff --git 
a/glance/tests/unit/async_/flows/plugins/test_inject_image_metadata.py 
b/glance/tests/unit/async_/flows/plugins/test_inject_image_metadata.py
+index 92a18a5..c62025b 100644
+--- a/glance/tests/unit/async_/flows/plugins/test_inject_image_metadata.py
++++ b/glance/tests/unit/async_/flows/plugins/test_inject_image_metadata.py
+@@ -65,7 +65,7 @@
+         self.img_repo.get.return_value = self.image
+ 
+         task_input = {
+-            "import_from": "http://cloud.foo/image.qcow2";,
++            "import_from": "http://198.51.100.1/image.qcow2";,
+             "import_from_format": "qcow2",
+             "image_properties": {'disk_format': 'qcow2',
+                                  'container_format': 'bare'}
+diff --git a/glance/tests/unit/async_/flows/test_convert.py 
b/glance/tests/unit/async_/flows/test_convert.py
+index aa65ab7..4393021 100644
+--- a/glance/tests/unit/async_/flows/test_convert.py
++++ b/glance/tests/unit/async_/flows/test_convert.py
+@@ -56,7 +56,7 @@
+                                                 container_format='bare')
+ 
+         task_input = {
+-            "import_from": "http://cloud.foo/image.raw";,
++            "import_from": "http://198.51.100.1/image.raw";,
+             "import_from_format": "raw",
+             "image_properties": {'disk_format': 'qcow2',
+                                  'container_format': 'bare'}
+diff --git a/glance/tests/unit/async_/flows/test_import.py 
b/glance/tests/unit/async_/flows/test_import.py
+index 5774e3e..621ccdd 100644
+--- a/glance/tests/unit/async_/flows/test_import.py
++++ b/glance/tests/unit/async_/flows/test_import.py
+@@ -77,7 +77,7 @@
+                                                 container_format='bare')
+ 
+         task_input = {
+-            "import_from": "http://cloud.foo/image.qcow2";,
++            "import_from": "http://198.51.100.1/image.qcow2";,
+             "import_from_format": "qcow2",
+             "image_properties": {'disk_format': 'qcow2',
+                                  'container_format': 'bare'}
+diff --git a/glance/tests/unit/async_/flows/test_introspect.py 
b/glance/tests/unit/async_/flows/test_introspect.py
+index f7c6542..b2f68dd 100644
+--- a/glance/tests/unit/async_/flows/test_introspect.py
++++ b/glance/tests/unit/async_/flows/test_introspect.py
+@@ -37,7 +37,7 @@
+         super(TestImportTask, self).setUp()
+         self.task_factory = domain.TaskFactory()
+         task_input = {
+-            "import_from": "http://cloud.foo/image.qcow2";,
++            "import_from": "http://198.51.100.1/image.qcow2";,
+             "import_from_format": "qcow2",
+             "image_properties": mock.sentinel.image_properties
+         }
+diff --git a/glance/tests/unit/async_/test_async.py 
b/glance/tests/unit/async_/test_async.py
+index 96c6e6e..20ad9a9 100644
+--- a/glance/tests/unit/async_/test_async.py
++++ b/glance/tests/unit/async_/test_async.py
+@@ -131,7 +131,7 @@
+         import_req = {
+             'method': {
+                 'name': 'web-download',
+-                'uri': 'http://cloud.foo/image.qcow2'
++                'uri': 'http://198.51.100.1/image.qcow2'
+             }
+         }
+ 
+diff --git a/glance/tests/unit/async_/test_taskflow_executor.py 
b/glance/tests/unit/async_/test_taskflow_executor.py
+index 397fac9..ce10f79 100644
+--- a/glance/tests/unit/async_/test_taskflow_executor.py
++++ b/glance/tests/unit/async_/test_taskflow_executor.py
+@@ -55,7 +55,7 @@
+         self.image_factory = mock.Mock()
+ 
+         task_input = {
+-            "import_from": "http://cloud.foo/image.qcow2";,
++            "import_from": "http://198.51.100.1/image.qcow2";,
+             "import_from_format": "qcow2",
+             "image_properties": {'disk_format': 'qcow2',
+                                  'container_format': 'bare'}
+@@ -79,6 +79,12 @@
+             self.image_repo,
+             self.image_factory)
+ 
++        self._addrinfo_patcher = mock.patch(
++            'glance.common.utils.socket.getaddrinfo',
++            return_value=[(None, None, None, None, ('203.0.113.1', 80))])
++        self._addrinfo_patcher.start()
++        self.addCleanup(self._addrinfo_patcher.stop)
++
+     def test_fetch_an_executor_parallel(self):
+         self.config(engine_mode='parallel', group='taskflow_executor')
+         pool = self.executor._fetch_an_executor()
+@@ -142,7 +148,7 @@
+                          'image_factory': self.image_factory,
+                          'backend': None,
+                          'admin_repo': admin_repo,
+-                         'uri': 'http://cloud.foo/image.qcow2'})
++                         'uri': 'http://198.51.100.1/image.qcow2'})
+ 
+     @mock.patch('stevedore.driver.DriverManager')
+     @mock.patch.object(taskflow_executor, 'LOG')
+diff --git a/glance/tests/unit/common/scripts/test_scripts_utils.py 
b/glance/tests/unit/common/scripts/test_scripts_utils.py
+index 38c9a40..914e81c 100644
+--- a/glance/tests/unit/common/scripts/test_scripts_utils.py
++++ b/glance/tests/unit/common/scripts/test_scripts_utils.py
+@@ -153,6 +153,26 @@
+         self.assertRaises(urllib.error.URLError,
+                           script_utils.validate_location_uri, location)
+ 
++    @mock.patch('glance.common.utils.socket.getaddrinfo')
++    def test_validate_legacy_import_from_uri_ok(self, mock_getaddrinfo):
++        mock_getaddrinfo.return_value = [
++            (None, None, None, None, ('203.0.113.1', 80))]
++        uri = 'http://example.com/img'
++        self.assertEqual(
++            uri, script_utils.validate_legacy_import_from_uri(uri))
++
++    @mock.patch('glance.common.utils.socket.getaddrinfo')
++    def test_validate_legacy_import_from_uri_filtered(self, mock_getaddrinfo):
++        mock_getaddrinfo.return_value = [
++            (None, None, None, None, ('127.0.0.1', 80))]
++        self.config(disallowed_hosts=['127.0.0.1'],
++                    group='import_filtering_opts')
++        self.config(allowed_ports=[80],
++                    group='import_filtering_opts')
++        self.assertRaises(exception.Invalid,
++                          script_utils.validate_legacy_import_from_uri,
++                          'http://127.0.0.1:80/x')
++
+ 
+ class TestCallbackIterator(test_utils.BaseTestCase):
+     def test_iterator_iterates(self):
+diff --git a/glance/tests/unit/v2/test_tasks_resource.py 
b/glance/tests/unit/v2/test_tasks_resource.py
+index 9961b52..4a4c10a 100644
+--- a/glance/tests/unit/v2/test_tasks_resource.py
++++ b/glance/tests/unit/v2/test_tasks_resource.py
+@@ -297,18 +297,22 @@
+         self.assertRaises(webob.exc.HTTPNotFound,
+                           self.controller.get, request, UUID4)
+ 
++    @mock.patch('glance.common.utils.socket.getaddrinfo')
+     @mock.patch('glance.api.common.get_thread_pool')
+     @mock.patch.object(glance.gateway.Gateway, 'get_task_factory')
+     @mock.patch.object(glance.gateway.Gateway, 'get_task_executor_factory')
+     @mock.patch.object(glance.gateway.Gateway, 'get_task_repo')
+     def test_create(self, mock_get_task_repo, mock_get_task_executor_factory,
+-                    mock_get_task_factory, mock_get_thread_pool):
++                    mock_get_task_factory, mock_get_thread_pool,
++                    mock_getaddrinfo):
++        mock_getaddrinfo.return_value = [
++            (None, None, None, None, ('203.0.113.1', 80))]
+         # setup
+         request = unit_test_utils.get_fake_request()
+         task = {
+             "type": "import",
+             "input": {
+-                "import_from": "swift://cloud.foo/myaccount/mycontainer/path",
++                "import_from": 
"http://example.com/myaccount/mycontainer/path";,
+                 "import_from_format": "qcow2",
+                 "image_properties": {}
+             }
+@@ -345,8 +349,8 @@
+             get_task_executor_factory.new_task_executor.return_value)
+ 
+     @mock.patch('glance.common.scripts.utils.get_image_data_iter')
+-    @mock.patch('glance.common.scripts.utils.validate_location_uri')
+-    def test_create_with_live_time(self, mock_validate_location_uri,
++    @mock.patch('glance.common.scripts.utils.validate_legacy_import_from_uri')
++    def test_create_with_live_time(self, mock_validate_legacy_import_from_uri,
+                                    mock_get_image_data_iter):
+         self.skipTest("Something wrong, this test touches registry")
+         request = unit_test_utils.get_fake_request()
+@@ -387,10 +391,6 @@
+             "file:///path",
+             "cinder://volume-id"
+         ]
+-        executor_factory = self.gateway.get_task_executor_factory(
+-            request.context)
+-        task_repo = self.gateway.get_task_repo(request.context)
+-
+         for import_from in wrong_import_from:
+             task = {
+                 "type": "import",
+@@ -404,12 +404,8 @@
+                     }
+                 }
+             }
+-            new_task = self.controller.create(request, task=task)
+-            task_executor = 
executor_factory.new_task_executor(request.context)
+-            task_executor.begin_processing(new_task.task_id)
+-            final_task = task_repo.get(new_task.task_id)
+-
+-            self.assertEqual('failure', final_task.status)
++            exc = self.assertRaises(webob.exc.HTTPBadRequest,
++                                    self.controller.create, request, 
task=task)
+             if import_from.startswith("file:///"):
+                 msg = ("File based imports are not allowed. Please use a "
+                        "non-local source of image data.")
+@@ -418,7 +414,33 @@
+                 msg = ("The given uri is not valid. Please specify a "
+                        "valid uri from the following list of supported uri "
+                        "%(supported)s") % {'supported': supported}
+-            self.assertEqual(msg, final_task.message)
++            self.assertEqual(msg, exc.explanation)
++
++    @mock.patch('glance.common.utils.socket.getaddrinfo')
++    def test_create_legacy_import_rejects_filtered_http_uri(
++            self, mock_getaddrinfo):
++        mock_getaddrinfo.return_value = [
++            (None, None, None, None, ('127.0.0.1', 80))]
++        self.config(disallowed_hosts=['127.0.0.1'],
++                    group='import_filtering_opts')
++        self.config(allowed_ports=[80],
++                    group='import_filtering_opts')
++        request = unit_test_utils.get_fake_request()
++        task = {
++            "type": "import",
++            "input": {
++                "import_from": "http://127.0.0.1:80/internal";,
++                "import_from_format": "qcow2",
++                "image_properties": {
++                    "disk_format": "qcow2",
++                    "container_format": "bare",
++                    "name": "test-task"
++                }
++            }
++        }
++        exc = self.assertRaises(webob.exc.HTTPBadRequest,
++                                self.controller.create, request, task=task)
++        self.assertIn('does not pass filtering', exc.explanation)
+ 
+     def test_create_with_properties_missed(self):
+         request = unit_test_utils.get_fake_request()
+@@ -426,14 +448,17 @@
+             request.context)
+         task_repo = self.gateway.get_task_repo(request.context)
+ 
+-        task = {
+-            "type": "import",
+-            "input": {
+-                "import_from": "swift://cloud.foo/myaccount/mycontainer/path",
+-                "import_from_format": "qcow2",
++        with mock.patch('glance.common.utils.socket.getaddrinfo',
++                        return_value=[(None, None, None, None,
++                                       ('203.0.113.1', 80))]):
++            task = {
++                "type": "import",
++                "input": {
++                    "import_from": "http://example.com/myaccount/path";,
++                    "import_from_format": "qcow2",
++                }
+             }
+-        }
+-        new_task = self.controller.create(request, task=task)
++            new_task = self.controller.create(request, task=task)
+         task_executor = executor_factory.new_task_executor(request.context)
+         task_executor.begin_processing(new_task.task_id)
+         final_task = task_repo.get(new_task.task_id)
+@@ -442,8 +467,12 @@
+         msg = "Input does not contain 'image_properties' field"
+         self.assertEqual(msg, final_task.message)
+ 
++    @mock.patch('glance.common.utils.socket.getaddrinfo')
+     @mock.patch.object(glance.gateway.Gateway, 'get_task_factory')
+-    def test_notifications_on_create(self, mock_get_task_factory):
++    def test_notifications_on_create(self, mock_get_task_factory,
++                                     mock_getaddrinfo):
++        mock_getaddrinfo.return_value = [
++            (None, None, None, None, ('203.0.113.1', 80))]
+         request = unit_test_utils.get_fake_request()
+ 
+         new_task = mock.MagicMock(type='import')
+diff --git a/releasenotes/notes/bug-2152110-8c4e91a2b3d0567f.yaml 
b/releasenotes/notes/bug-2152110-8c4e91a2b3d0567f.yaml
+new file mode 100644
+index 0000000..5434db6
+--- /dev/null
++++ b/releasenotes/notes/bug-2152110-8c4e91a2b3d0567f.yaml
+@@ -0,0 +1,29 @@
++---
++security:
++  - |
++    Fixed insufficient validation of ``import_from`` URIs for legacy
++    ``type=import`` tasks created through the deprecated Task API
++    (``POST /v2/tasks``). Those requests were not held to the same URI
++    rules as modern image import, so Server-Side Request Forgery (SSRF)
++    protections could be weaker on that path.
++
++    Impact:
++
++    - Severity: High (SSRF-style exposure through attacker-controlled
++      ``import_from`` URIs on the legacy import task path)
++    - Affected versions: All versions prior to this fix that still
++      expose the Task API for ``type=import`` tasks
++
++    Mitigation without upgrading (especially for unmaintained
++    releases):
++
++    The Task API has been deprecated for a long time; the simplest
++    mitigation is to block it in your deployment, for example set
++    ``tasks_api_access`` to a check that never matches (such as ``!``
++    in a policy YAML file) so ``/v2/tasks`` is denied for all callers.
++
++fixes:
++  - |
++    `Bug 2152110 <https://bugs.launchpad.net/glance/+bug/2152110>`_:
++    Align legacy ``type=import`` task URI checks with modern image
++    import so ``import_from`` cannot bypass the same restrictions.
diff -Nru glance-30.0.0/debian/patches/series 
glance-30.0.0/debian/patches/series
--- glance-30.0.0/debian/patches/series 2026-04-27 08:23:24.000000000 +0200
+++ glance-30.0.0/debian/patches/series 2026-08-20 14:02:16.000000000 +0200
@@ -2,3 +2,4 @@
 missing-files.patch
 CVE-2026-34881_OSSA-2026-004_Fix_SSRF_vulnerabilities_in_image_import_API.patch
 No_DNS_resolution_in_test.patch
+OSSN-0105_Apply_import_URI_filtering_to_legacy_import_tasks.patch 

Reply via email to