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

