Version in base suite: 30.0.0-3+deb13u2 Base version: glance_30.0.0-3+deb13u2 Target version: glance_30.0.0-3+deb13u4 Base file: /srv/ftp-master.debian.org/ftp/pool/main/g/glance/glance_30.0.0-3+deb13u2.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/g/glance/glance_30.0.0-3+deb13u4.dsc changelog | 21 patches/CVE-2026-71196-71197-71198_1_Properly_limit_web-download_image_fetch_size.patch | 180 + patches/CVE-2026-71196-71197-71198_2_Block_restricted_addresses_in_web-download_import_URIs.patch | 615 ++++++ patches/CVE-2026-71196-71197-71198_3_Pin_import_downloads_to_validated_destination_addresses.patch | 1001 ++++++++++ patches/CVE-2026-71196-71197-71198_4_Block_restricted_hosts_when_adding_HTTP_image_locations.patch | 792 +++++++ patches/OSSN-0105_Apply_import_URI_filtering_to_legacy_import_tasks.patch | 436 ++++ patches/series | 5 7 files changed, 3050 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpe3ois1ni/glance_30.0.0-3+deb13u2.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpe3ois1ni/glance_30.0.0-3+deb13u4.dsc: no acceptable signature found diff -Nru glance-30.0.0/debian/changelog glance-30.0.0/debian/changelog --- glance-30.0.0/debian/changelog 2026-04-27 06:23:24.000000000 +0000 +++ glance-30.0.0/debian/changelog 2026-08-31 20:49:17.000000000 +0000 @@ -1,3 +1,24 @@ +glance (2:30.0.0-3+deb13u4) trixie; urgency=medium + + * CVE-2026-71196, CVE-2026-71197, CVE-2026-71198: Multiple SSRF + vulnerabilities. Applied upstream patches: + - CVE-2026-71196-71197-71198_1_Properly_limit_web-download_image_f....patch + - CVE-2026-71196-71197-71198_2_Block_restricted_addresses_in_web-d....patch + - CVE-2026-71196-71197-71198_3_Pin_import_downloads_to_validated_d....patch + - CVE-2026-71196-71197-71198_4_Block_restricted_hosts_when_adding_....patch + (Closes: #1146594) + + -- Thomas Goirand Mon, 31 Aug 2026 22:49:17 +0200 + +glance (2:30.0.0-3+deb13u3) trixie; urgency=medium + + * CVE-2026-77648 / 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 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/CVE-2026-71196-71197-71198_1_Properly_limit_web-download_image_fetch_size.patch glance-30.0.0/debian/patches/CVE-2026-71196-71197-71198_1_Properly_limit_web-download_image_fetch_size.patch --- glance-30.0.0/debian/patches/CVE-2026-71196-71197-71198_1_Properly_limit_web-download_image_fetch_size.patch 1970-01-01 00:00:00.000000000 +0000 +++ glance-30.0.0/debian/patches/CVE-2026-71196-71197-71198_1_Properly_limit_web-download_image_fetch_size.patch 2026-08-31 20:49:17.000000000 +0000 @@ -0,0 +1,180 @@ +Description: Properly limit web-download image fetch size + If web-download is pointed at a source that does not provide a + Content-Length header, we will read until the end of stream. This + could fill our disk if the source is unlimited (or insanely large). + This change makes us honor the existing image_size_cap restriction + we enforce elsewhere to avoid this unbounded behavior. + . + On stable/2025.1, get_image_data_iter() still returns a single + iterable (not (data, size)). Keep that return shape and preserve + headers/close on the LimitingReader wrapper for existing callers. +Author: Dan Smith +Date: Wed, 15 Jul 2026 06:37:45 -0700 +Bug: https://bugs.launchpad.net/glance/+bug/2160020 +Bug-Debian: https://bugs.debian.org/1146594 +Generated-By: Claude Opus 4.6 +Change-Id: Ie8d11f2b822d85fb5f4ed5c96fe9c5ed352d5a9c +Origin: pre-OSSA mailing list +Last-Update: 2026-08-28 + +Index: glance/glance/common/scripts/utils.py +=================================================================== +--- glance.orig/glance/common/scripts/utils.py ++++ glance/glance/common/scripts/utils.py +@@ -27,6 +27,7 @@ import urllib + import urllib.error + import urllib.request + ++from oslo_config import cfg + from oslo_log import log as logging + from oslo_utils import timeutils + +@@ -34,6 +35,7 @@ from glance.common import exception + from glance.common import utils as common_utils + from glance.i18n import _, _LE + ++CONF = cfg.CONF + LOG = logging.getLogger(__name__) + + +@@ -170,10 +172,28 @@ def get_image_data_iter(uri): + # + # We're not using StringIO or other tools to avoid reading everything + # into memory. Some images may be quite heavy. +- return open(uri, "rb") ++ data = open(uri, "rb") ++ return _size_limited_reader(data) + + opener = urllib.request.build_opener(SafeRedirectHandler) +- return opener.open(uri) ++ return _size_limited_reader(opener.open(uri)) ++ ++ ++def _size_limited_reader(data): ++ """Wrap image data with CooperativeReader and LimitingReader. ++ ++ Preserves headers/close from the underlying object for callers that still ++ expect a single iterable (stable/2025.2 has not adopted the (data, size) ++ return shape from newer branches). ++ """ ++ limited = common_utils.LimitingReader( ++ common_utils.CooperativeReader(data), ++ CONF.image_size_cap) ++ if hasattr(data, 'headers'): ++ limited.headers = data.headers ++ if hasattr(data, 'close'): ++ limited.close = data.close ++ return limited + + + class CallbackIterator(object): +Index: glance/glance/tests/unit/async_/flows/test_web_download.py +=================================================================== +--- glance.orig/glance/tests/unit/async_/flows/test_web_download.py ++++ glance/glance/tests/unit/async_/flows/test_web_download.py +@@ -22,6 +22,7 @@ from glance.async_.flows._internal_plugi + from glance.async_.flows import api_image_import + import glance.common.exception + import glance.common.scripts.utils as script_utils ++from glance.common import utils as common_utils + from glance import domain + import glance.tests.utils as test_utils + +@@ -145,3 +146,21 @@ class TestWebDownloadTask(test_utils.Bas + mock_iter.return_value.headers = {'content-length': '4'} + self.assertRaises(glance.common.exception.ImportTaskError, + self.web_download_task.execute) ++ ++ @mock.patch.object(filesystem.Store, 'add') ++ @mock.patch('urllib.request.build_opener') ++ def test_web_download_data_is_size_limited(self, mock_build_opener, ++ mock_add): ++ mock_response = mock.MagicMock() ++ mock_response.headers = {'content-length': '4'} ++ mock_opener = mock.MagicMock() ++ mock_opener.open.return_value = mock_response ++ mock_build_opener.return_value = mock_opener ++ mock_add.return_value = ["path", 4] ++ ++ self.web_download_task.uri = 'http://example.com/image.qcow2' ++ self.web_download_task.execute() ++ ++ data_arg = mock_add.call_args[0][1] ++ self.assertIsInstance(data_arg, common_utils.LimitingReader) ++ self.assertEqual(data_arg.limit, CONF.image_size_cap) +Index: glance/glance/tests/unit/common/scripts/test_scripts_utils.py +=================================================================== +--- glance.orig/glance/tests/unit/common/scripts/test_scripts_utils.py ++++ glance/glance/tests/unit/common/scripts/test_scripts_utils.py +@@ -20,6 +20,7 @@ import urllib.request + + from glance.common import exception + from glance.common.scripts import utils as script_utils ++from glance.common import utils as common_utils + import glance.tests.utils as test_utils + + +@@ -312,8 +313,11 @@ class TestGetImageDataIter(test_utils.Ba + def setUp(self): + super(TestGetImageDataIter, self).setUp() + ++ @mock.patch.object(common_utils, 'LimitingReader') ++ @mock.patch.object(common_utils, 'CooperativeReader') + @mock.patch('builtins.open', create=True) +- def test_get_image_data_iter_file_uri(self, mock_open): ++ def test_get_image_data_iter_file_uri(self, mock_open, ++ mock_coop, mock_limit): + """Test file:// URI handling.""" + mock_file = mock.Mock() + mock_open.return_value = mock_file +@@ -321,17 +325,24 @@ class TestGetImageDataIter(test_utils.Ba + result = script_utils.get_image_data_iter("file:///tmp/test.img") + + mock_open.assert_called_once_with("/tmp/test.img", "rb") +- self.assertEqual(result, mock_file) ++ mock_coop.assert_called_once_with(mock_file) ++ mock_limit.assert_called_once_with(mock_coop.return_value, ++ script_utils.CONF.image_size_cap) ++ self.assertEqual(result, mock_limit.return_value) + ++ @mock.patch.object(common_utils, 'LimitingReader') ++ @mock.patch.object(common_utils, 'CooperativeReader') + @mock.patch('urllib.request.build_opener') +- def test_get_image_data_iter_http_uri(self, mock_build_opener): ++ def test_get_image_data_iter_http_uri(self, mock_build_opener, ++ mock_coop, mock_limit): + """Test HTTP URI handling with redirect validation.""" + mock_opener = mock.Mock() + mock_response = mock.Mock() + mock_opener.open.return_value = mock_response + mock_build_opener.return_value = mock_opener + +- result = script_utils.get_image_data_iter("http://example.com/image") ++ result = script_utils.get_image_data_iter( ++ "http://example.com/image") + + # Should use build_opener with SafeRedirectHandler + mock_build_opener.assert_called_once() +@@ -358,4 +369,7 @@ class TestGetImageDataIter(test_utils.Ba + "SafeRedirectHandler should be passed to build_opener") + + mock_opener.open.assert_called_once_with("http://example.com/image") +- self.assertEqual(result, mock_response) ++ mock_coop.assert_called_once_with(mock_response) ++ mock_limit.assert_called_once_with(mock_coop.return_value, ++ script_utils.CONF.image_size_cap) ++ self.assertEqual(result, mock_limit.return_value) +Index: glance/releasenotes/notes/unbounded-web-download-80b90558aa417f6c.yaml +=================================================================== +--- /dev/null ++++ glance/releasenotes/notes/unbounded-web-download-80b90558aa417f6c.yaml +@@ -0,0 +1,9 @@ ++--- ++security: ++ - | ++ A bug in the web-download import plugin was fixed which would allow a ++ remote URI to omit the Content-Length header and feed glance data forever ++ until the staging disk filled. We now honor the image_size_cap limit for ++ these streams. Note that the default value for this limit is 1TiB, which ++ may be higher than is reasonable for some deployments. Operators should ++ take this opportunity to review that setting for correctness. diff -Nru glance-30.0.0/debian/patches/CVE-2026-71196-71197-71198_2_Block_restricted_addresses_in_web-download_import_URIs.patch glance-30.0.0/debian/patches/CVE-2026-71196-71197-71198_2_Block_restricted_addresses_in_web-download_import_URIs.patch --- glance-30.0.0/debian/patches/CVE-2026-71196-71197-71198_2_Block_restricted_addresses_in_web-download_import_URIs.patch 1970-01-01 00:00:00.000000000 +0000 +++ glance-30.0.0/debian/patches/CVE-2026-71196-71197-71198_2_Block_restricted_addresses_in_web-download_import_URIs.patch 2026-08-31 20:49:17.000000000 +0000 @@ -0,0 +1,615 @@ +Description: Block restricted addresses in web-download import URIs + Reject import URIs targeting loopback, link-local, private, reserved, + multicast, or unspecified addresses unless the host is listed in + allowed_hosts. Also reject hostnames that resolve to those addresses. + . + On stable/2025.1, FunctionalTest servers need allowed_hosts in conf + (via self.allowed_hosts) because self.config() does not affect the + API subprocess. +Author: Abhishek Kekane +Date: Tue, 7 Jul 2026 06:37:32 +0000 +Bug: https://bugs.launchpad.net/glance/+bug/2158998 +Bug-Debian: https://bugs.debian.org/1146594 +Assisted-By: Cursor (claude-4.5-sonnet) +Change-Id: I49c926fdeeac75f72e19dbf8c2124b46a1f12f37 +Signed-off-by: Abhishek Kekane +Origin: upstream, pre-OSSA mailing list +Last-Update: 2026-08-28 + +Index: glance/doc/source/admin/interoperable-image-import.rst +=================================================================== +--- glance.orig/doc/source/admin/interoperable-image-import.rst ++++ glance/doc/source/admin/interoperable-image-import.rst +@@ -225,9 +225,15 @@ Validation of a URI happens as follows: + + a. missing hostname: reject + b. If there's a whitelist, and the host is not in it: reject. Otherwise, +- skip c and continue on to 3. ++ skip c and d and continue on to 3. + c. If there's a blacklist, and the host is in it: reject. + ++ d. Unless the host is listed in ``allowed_hosts``, reject URIs that ++ target loopback or link-local IP addresses (for example ++ ``127.0.0.1`` or cloud metadata at ``169.254.169.254``). Hostnames ++ that resolve to any of those address types are also rejected. ++ Private RFC1918 addresses are not blocked by this check. ++ + 3. If there's a port in the URI, the port is checked. + + a. If there's a whitelist, and the port is not in it: reject. Otherwise, +@@ -254,10 +260,21 @@ settings for these options: + * ``allowed_ports`` - ``[80, 443]`` + * ``disallowed_ports`` - empty list + ++In addition to the host allow/deny lists above, Glance always rejects ++import URIs that target loopback or link-local IP addresses unless the ++host is explicitly listed in ``allowed_hosts``. Hostnames that resolve ++to any of those address types are also rejected. Private RFC1918 ++addresses (for example ``10.0.0.0/8`` or ``192.168.0.0/16``) are not ++blocked by default. ++ + Thus if you use the defaults, end users will only be able to access URIs +-using the http or https scheme. The only ports users will be able to specify +-are 80 and 443. (Users do not have to specify a port, but if they do, it must +-be either 80 or 443.) ++using the http or https scheme. The only ports users will be able to ++specify are 80 and 443. (Users do not have to specify a port, but if ++they do, it must be either 80 or 443.) Loopback and link-local ++destinations remain blocked unless listed in ``allowed_hosts``. ++ ++To permit imports from loopback or link-local image sources, add the ++trusted hostnames or IP addresses to ``allowed_hosts``. + + .. note:: + The **glance-image-import.conf** is an optional file. You can find an +Index: glance/glance/common/utils.py +=================================================================== +--- glance.orig/glance/common/utils.py ++++ glance/glance/common/utils.py +@@ -186,6 +186,44 @@ def normalize_hostname(host): + return host + + ++def _is_restricted_import_host(normalized_host): ++ """Return True if host is or resolves to a restricted address. ++ ++ Blocks loopback and link-local addresses (including cloud metadata ++ endpoints such as 169.254.169.254). Private RFC1918 ranges are not ++ blocked so private clouds can import from internal hosts. IPv4-mapped ++ IPv6 addresses are checked against the embedded IPv4 address. For ++ hostnames, any resolved address that is restricted causes rejection ++ (fail closed on DNS errors). ++ """ ++ try: ++ addresses = [ipaddress.ip_address(normalized_host)] ++ except ValueError: ++ testhost = normalized_host if normalized_host.endswith('.') else ( ++ normalized_host + '.') ++ try: ++ addresses = [] ++ for result in socket.getaddrinfo(testhost, None): ++ ip = result[4][0] ++ if not ip: ++ return True ++ if '%' in ip: ++ ip = ip.split('%', 1)[0] ++ try: ++ addresses.append(ipaddress.ip_address(ip)) ++ except ValueError: ++ return True ++ except socket.gaierror: ++ return True ++ ++ for addr in addresses: ++ if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped: ++ addr = addr.ipv4_mapped ++ if addr.is_loopback or addr.is_link_local: ++ return True ++ return False ++ ++ + def validate_import_uri(uri): + """Validate requested uri for Image Import web-download. + +@@ -231,6 +269,11 @@ def validate_import_uri(uri): + normalized_host in bl_hosts): + return False + ++ host_is_whitelisted = bool(wl_hosts and normalized_host in wl_hosts) ++ if not host_is_whitelisted and _is_restricted_import_host( ++ normalized_host): ++ return False ++ + if port and ((wl_ports and port not in wl_ports) or + port in bl_ports): + return False +Index: glance/glance/tests/functional/__init__.py +=================================================================== +--- glance.orig/glance/tests/functional/__init__.py ++++ glance/glance/tests/functional/__init__.py +@@ -117,6 +117,11 @@ class BaseServer(metaclass=abc.ABCMeta): + if kwargs: + conf_override.update(**kwargs) + ++ # HostAddress ListOpt expects [host1,host2] without quotes. ++ allowed_hosts = conf_override.get('allowed_hosts') ++ if isinstance(allowed_hosts, (list, tuple)): ++ conf_override['allowed_hosts'] = '[%s]' % ','.join(allowed_hosts) ++ + # A config file and paste.ini to use just for this test...we don't want + # to trample on currently-running Glance servers, now do we? + +@@ -342,6 +347,8 @@ class ApiServer(Server): + self.node_staging_uri = 'file://%s' % os.path.join( + self.test_dir, 'staging') + ++ self.allowed_hosts = [] ++ + self.conf_base = """[DEFAULT] + debug = %(debug)s + default_log_levels = eventlet.wsgi.server=DEBUG,stevedore.extension=INFO +@@ -380,6 +387,7 @@ flavor = %(deployment_flavor)s + filesystem_store_datadir=%(image_dir)s + default_store = %(default_store)s + [import_filtering_opts] ++allowed_hosts = %(allowed_hosts)s + allowed_ports = [] + """ + self.paste_conf_base = """[composite:glance-api] +@@ -524,6 +532,8 @@ class ApiServerForMultipleBackend(Server + self.user_storage_quota = '0' + self.lock_path = self.test_dir + ++ self.allowed_hosts = [] ++ + self.conf_base = """[DEFAULT] + debug = %(debug)s + default_log_levels = eventlet.wsgi.server=DEBUG,stevedore.extension=INFO +@@ -567,6 +577,7 @@ filesystem_store_datadir=%(image_dir_bac + [file3] + filesystem_store_datadir=%(image_dir_backend_3)s + [import_filtering_opts] ++allowed_hosts = %(allowed_hosts)s + allowed_ports = [] + [os_glance_staging_store] + filesystem_store_datadir=%(staging_dir)s +Index: glance/glance/tests/functional/v2/test_images.py +=================================================================== +--- glance.orig/glance/tests/functional/v2/test_images.py ++++ glance/glance/tests/functional/v2/test_images.py +@@ -244,6 +244,7 @@ class TestImages(functional.FunctionalTe + self.stop_servers() + + def test_image_import_using_web_download(self): ++ self.allowed_hosts = ['localhost'] + self.start_servers(**self.__dict__.copy()) + + # Image list should be empty +@@ -388,6 +389,7 @@ class TestImages(functional.FunctionalTe + def test_web_download_redirect_validation(self): + """Test that redirect destinations are validated.""" + self.config(allowed_ports=[80], group='import_filtering_opts') ++ self.allowed_hosts = ['localhost'] + self.config(disallowed_hosts=['127.0.0.1'], + group='import_filtering_opts') + self.start_servers(**self.__dict__.copy()) +@@ -426,7 +428,7 @@ class TestImages(functional.FunctionalTe + redirect_thread.daemon = True + redirect_thread.start() + +- redirect_uri = 'http://127.0.0.1:%s/' % redirect_port ++ redirect_uri = 'http://localhost:%s/' % redirect_port + path = self._url('/v2/images/%s/import' % image_id) + headers = self._headers({ + 'content-type': 'application/json', +@@ -481,6 +483,37 @@ class TestImages(functional.FunctionalTe + + self.stop_servers() + ++ def test_web_download_blocks_restricted_hosts(self): ++ """Test that restricted addresses are rejected at import time.""" ++ self.start_servers(**self.__dict__.copy()) ++ ++ path = self._url('/v2/images') ++ headers = self._headers({'content-type': 'application/json'}) ++ data = jsonutils.dumps({ ++ 'name': 'ssrf-test', 'type': 'kernel', ++ 'disk_format': 'aki', 'container_format': 'aki'}) ++ response = requests.post(path, headers=headers, data=data) ++ self.assertEqual(http.CREATED, response.status_code) ++ image_id = jsonutils.loads(response.text)['id'] ++ ++ path = self._url('/v2/images/%s/import' % image_id) ++ headers = self._headers({ ++ 'content-type': 'application/json', ++ 'X-Roles': 'admin', ++ }) ++ data = jsonutils.dumps({'method': { ++ 'name': 'web-download', ++ 'uri': 'http://169.254.169.254/latest/meta-data/' ++ }}) ++ response = requests.post(path, headers=headers, data=data) ++ self.assertEqual(http.BAD_REQUEST, response.status_code) ++ ++ path = self._url('/v2/images/%s' % image_id) ++ response = requests.delete(path, headers=self._headers()) ++ self.assertEqual(http.NO_CONTENT, response.status_code) ++ ++ self.stop_servers() ++ + def test_web_download_ip_normalization(self): + """Test that encoded IP addresses are normalized and blocked.""" + self.config(allowed_ports=[80], group='import_filtering_opts') +@@ -5398,6 +5431,7 @@ class TestImagesMultipleBackend(function + self.stop_servers() + + def test_image_import_using_web_download(self): ++ self.allowed_hosts = ['localhost'] + self.start_servers(**self.__dict__.copy()) + + # Image list should be empty +@@ -5562,6 +5596,7 @@ class TestImagesMultipleBackend(function + self.stop_servers() + + def test_image_import_using_web_download_different_backend(self): ++ self.allowed_hosts = ['localhost'] + self.start_servers(**self.__dict__.copy()) + + # Image list should be empty +@@ -5727,6 +5762,7 @@ class TestImagesMultipleBackend(function + self.stop_servers() + + def test_image_import_multi_stores(self): ++ self.allowed_hosts = ['localhost'] + self.start_servers(**self.__dict__.copy()) + + # Image list should be empty +@@ -5891,6 +5927,7 @@ class TestImagesMultipleBackend(function + self.stop_servers() + + def test_copy_image_lifecycle(self): ++ self.allowed_hosts = ['localhost'] + self.start_servers(**self.__dict__.copy()) + + # Image list should be empty +@@ -6117,6 +6154,7 @@ class TestImagesMultipleBackend(function + # Test if copying task fails in between then the rollback + # should delete the data from only stores to which it is + # copied and not from the existing stores. ++ self.allowed_hosts = ['localhost'] + self.start_servers(**self.__dict__.copy()) + + # Image list should be empty +@@ -6376,6 +6414,7 @@ class TestImagesMultipleBackend(function + self.stop_servers() + + def test_image_import_multi_stores_specifying_all_stores(self): ++ self.allowed_hosts = ['localhost'] + self.start_servers(**self.__dict__.copy()) + + # Image list should be empty +@@ -7376,6 +7415,7 @@ class TestCopyImagePermissions(functiona + return image_id + + def _test_copy_public_image_as_non_admin(self): ++ self.allowed_hosts = ['localhost'] + self.start_servers(**self.__dict__.copy()) + + # Create a publicly-visible image as TENANT1 +Index: glance/glance/tests/unit/async_/flows/plugins/test_image_conversion.py +=================================================================== +--- glance.orig/glance/tests/unit/async_/flows/plugins/test_image_conversion.py ++++ glance/glance/tests/unit/async_/flows/plugins/test_image_conversion.py +@@ -70,7 +70,7 @@ class TestConvertImageTask(test_utils.Ba + container_format='bare') + + task_input = { +- "import_from": "http://198.51.100.1/image.raw", ++ "import_from": "http://93.184.216.34/image.raw", + "import_from_format": "raw", + "image_properties": {'disk_format': 'raw', + 'container_format': 'bare'} +Index: glance/glance/tests/unit/async_/flows/plugins/test_inject_image_metadata.py +=================================================================== +--- glance.orig/glance/tests/unit/async_/flows/plugins/test_inject_image_metadata.py ++++ glance/glance/tests/unit/async_/flows/plugins/test_inject_image_metadata.py +@@ -65,7 +65,7 @@ class TestInjectImageMetadataTask(test_u + self.img_repo.get.return_value = self.image + + task_input = { +- "import_from": "http://198.51.100.1/image.qcow2", ++ "import_from": "http://93.184.216.34/image.qcow2", + "import_from_format": "qcow2", + "image_properties": {'disk_format': 'qcow2', + 'container_format': 'bare'} +Index: glance/glance/tests/unit/async_/flows/test_convert.py +=================================================================== +--- glance.orig/glance/tests/unit/async_/flows/test_convert.py ++++ glance/glance/tests/unit/async_/flows/test_convert.py +@@ -56,7 +56,7 @@ class TestImportTask(test_utils.BaseTest + container_format='bare') + + task_input = { +- "import_from": "http://198.51.100.1/image.raw", ++ "import_from": "http://93.184.216.34/image.raw", + "import_from_format": "raw", + "image_properties": {'disk_format': 'qcow2', + 'container_format': 'bare'} +Index: glance/glance/tests/unit/async_/flows/test_glance_download.py +=================================================================== +--- glance.orig/glance/tests/unit/async_/flows/test_glance_download.py ++++ glance/glance/tests/unit/async_/flows/test_glance_download.py +@@ -77,7 +77,8 @@ class TestGlanceDownloadTask(test_utils. + @mock.patch.object(filesystem.Store, 'add') + @mock.patch('glance.async_.utils.get_glance_endpoint') + def test_glance_download(self, mock_gge, mock_add, mock_getaddrinfo): +- mock_getaddrinfo.return_value = [('', '', '', '', ('', 80))] ++ mock_getaddrinfo.return_value = [ ++ ('', '', '', '', ('93.184.216.34', 80))] + mock_gge.return_value = 'https://other.cloud.foo/image' + glance_download_task = glance_download._DownloadGlanceImage( + self.context, self.task.task_id, self.task_type, +@@ -105,7 +106,8 @@ class TestGlanceDownloadTask(test_utils. + @mock.patch('glance.async_.utils.get_glance_endpoint') + def test_glance_download_failed(self, mock_gge, mock_add, + mock_getaddrinfo): +- mock_getaddrinfo.return_value = [('', '', '', '', ('', 80))] ++ mock_getaddrinfo.return_value = [ ++ ('', '', '', '', ('93.184.216.34', 80))] + mock_gge.return_value = 'https://other.cloud.foo/image' + glance_download_task = glance_download._DownloadGlanceImage( + self.context, self.task.task_id, self.task_type, +@@ -145,7 +147,8 @@ class TestGlanceDownloadTask(test_utils. + @mock.patch('glance.async_.utils.get_glance_endpoint') + def test_glance_download_size_mismatch(self, mock_gge, mock_add, + mock_getaddrinfo): +- mock_getaddrinfo.return_value = [('', '', '', '', ('', 80))] ++ mock_getaddrinfo.return_value = [ ++ ('', '', '', '', ('93.184.216.34', 80))] + mock_gge.return_value = 'https://other.cloud.foo/image' + glance_download_task = glance_download._DownloadGlanceImage( + self.context, self.task.task_id, self.task_type, +@@ -193,7 +196,8 @@ class TestGlanceDownloadTask(test_utils. + mock_request, + mock_getaddrinfo): + """Test redirect destinations are validated during image download.""" +- mock_getaddrinfo.return_value = [('', '', '', '', ('', 80))] ++ mock_getaddrinfo.return_value = [ ++ ('', '', '', '', ('93.184.216.34', 80))] + mock_gge.return_value = 'https://other.cloud.foo/image' + glance_download_task = glance_download._DownloadGlanceImage( + self.context, self.task.task_id, self.task_type, +@@ -226,7 +230,8 @@ class TestGlanceDownloadTask(test_utils. + def test_glance_download_uses_safe_redirect_handler( + self, mock_gge, mock_request, mock_add, mock_getaddrinfo): + """Test that SafeRedirectHandler is used and allows valid execution.""" +- mock_getaddrinfo.return_value = [('', '', '', '', ('', 80))] ++ mock_getaddrinfo.return_value = [ ++ ('', '', '', '', ('93.184.216.34', 80))] + mock_gge.return_value = 'https://other.cloud.foo/image' + glance_download_task = glance_download._DownloadGlanceImage( + self.context, self.task.task_id, self.task_type, +Index: glance/glance/tests/unit/async_/flows/test_import.py +=================================================================== +--- glance.orig/glance/tests/unit/async_/flows/test_import.py ++++ glance/glance/tests/unit/async_/flows/test_import.py +@@ -77,7 +77,7 @@ class TestImportTask(test_utils.BaseTest + container_format='bare') + + task_input = { +- "import_from": "http://198.51.100.1/image.qcow2", ++ "import_from": "http://93.184.216.34/image.qcow2", + "import_from_format": "qcow2", + "image_properties": {'disk_format': 'qcow2', + 'container_format': 'bare'} +@@ -458,10 +458,10 @@ class TestImportTask(test_utils.BaseTest + self.task_type) + + data = [b"test"] +- ++ image_size = len(b"test") + store = glance_store.get_store_from_scheme('file') + path = glance_store.store_add_to_backend(mock.sentinel.image_id, data, +- mock.sentinel.image_size, ++ image_size, + store, context=None)[0] + + path_wo_scheme = path.split("file://")[1] +Index: glance/glance/tests/unit/async_/flows/test_introspect.py +=================================================================== +--- glance.orig/glance/tests/unit/async_/flows/test_introspect.py ++++ glance/glance/tests/unit/async_/flows/test_introspect.py +@@ -37,7 +37,7 @@ class TestImportTask(test_utils.BaseTest + super(TestImportTask, self).setUp() + self.task_factory = domain.TaskFactory() + task_input = { +- "import_from": "http://198.51.100.1/image.qcow2", ++ "import_from": "http://93.184.216.34/image.qcow2", + "import_from_format": "qcow2", + "image_properties": mock.sentinel.image_properties + } +Index: glance/glance/tests/unit/async_/test_async.py +=================================================================== +--- glance.orig/glance/tests/unit/async_/test_async.py ++++ glance/glance/tests/unit/async_/test_async.py +@@ -131,7 +131,7 @@ class TestImportTaskFlow(test_utils.Base + import_req = { + 'method': { + 'name': 'web-download', +- 'uri': 'http://198.51.100.1/image.qcow2' ++ 'uri': 'http://93.184.216.34/image.qcow2' + } + } + +Index: glance/glance/tests/unit/async_/test_taskflow_executor.py +=================================================================== +--- glance.orig/glance/tests/unit/async_/test_taskflow_executor.py ++++ glance/glance/tests/unit/async_/test_taskflow_executor.py +@@ -55,7 +55,7 @@ class TestTaskExecutor(test_utils.BaseTe + self.image_factory = mock.Mock() + + task_input = { +- "import_from": "http://198.51.100.1/image.qcow2", ++ "import_from": "http://93.184.216.34/image.qcow2", + "import_from_format": "qcow2", + "image_properties": {'disk_format': 'qcow2', + 'container_format': 'bare'} +@@ -85,6 +85,12 @@ class TestTaskExecutor(test_utils.BaseTe + self._addrinfo_patcher.start() + self.addCleanup(self._addrinfo_patcher.stop) + ++ self._addrinfo_patcher = mock.patch( ++ 'glance.common.utils.socket.getaddrinfo', ++ return_value=[(None, None, None, None, ('93.184.216.34', 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() +@@ -148,7 +154,7 @@ class TestTaskExecutor(test_utils.BaseTe + 'image_factory': self.image_factory, + 'backend': None, + 'admin_repo': admin_repo, +- 'uri': 'http://198.51.100.1/image.qcow2'}) ++ 'uri': 'http://93.184.216.34/image.qcow2'}) + + @mock.patch('stevedore.driver.DriverManager') + @mock.patch.object(taskflow_executor, 'LOG') +Index: glance/glance/tests/unit/common/test_utils.py +=================================================================== +--- glance.orig/glance/tests/unit/common/test_utils.py ++++ glance/glance/tests/unit/common/test_utils.py +@@ -955,12 +955,13 @@ class EvaluateFilterOpTestCase(test_util + + class ImportURITestCase(test_utils.BaseTestCase): + +- @mock.patch("eventlet.green.socket.getaddrinfo") ++ @mock.patch("glance.common.utils.socket.getaddrinfo") + def test_validate_import_uri(self, mock_getaddrinfo): + # This avoid internet access in validate_import_uri() + # (ie: DNS resolution of foo.com) + mock_getaddrinfo.return_value = [ +- (None, None, None, None, ("127.0.0.1", 80)) ++ (socket.AF_INET, socket.SOCK_STREAM, 6, '', ++ ('93.184.216.34', 80)) + ] + + self.assertTrue(utils.validate_import_uri("http://foo.com")) +@@ -993,12 +994,13 @@ class ImportURITestCase(test_utils.BaseT + group='import_filtering_opts') + self.assertFalse(utils.validate_import_uri("http://localhost:8484")) + +- @mock.patch("eventlet.green.socket.getaddrinfo") ++ @mock.patch("glance.common.utils.socket.getaddrinfo") + def test_ignored_filtering_options(self, mock_getaddrinfo): + # This avoid internet access in validate_import_uri() + # (ie: DNS resolution of foo.com) + mock_getaddrinfo.return_value = [ +- (None, None, None, None, ("127.0.0.1", 80)) ++ (socket.AF_INET, socket.SOCK_STREAM, 6, '', ++ ('93.184.216.34', 80)) + ] + + LOG = logging.getLogger('glance.common.utils') +@@ -1034,12 +1036,13 @@ class ImportURITestCase(test_utils.BaseT + + def test_validate_import_uri_ip_rejection(self): + """Test that encoded IP addresses are rejected (not normalized).""" +- # Test that standard IP is blocked when in blacklist +- self.config(disallowed_hosts=['127.0.0.1'], +- group='import_filtering_opts') +- self.config(allowed_ports=[80], +- group='import_filtering_opts') ++ self.config(allowed_ports=[80], group='import_filtering_opts') ++ # Loopback and link-local addresses are blocked by default + self.assertFalse(utils.validate_import_uri("http://127.0.0.1:80/")) ++ self.assertFalse(utils.validate_import_uri("http://169.254.169.254/")) ++ # Private RFC1918 addresses are allowed by default ++ self.assertTrue(utils.validate_import_uri("http://10.0.0.1/")) ++ self.assertTrue(utils.validate_import_uri("http://192.168.1.1/")) + + # Test that encoded IP (decimal) is rejected + result = utils.validate_import_uri("http://2130706433:80/") +@@ -1050,7 +1053,7 @@ class ImportURITestCase(test_utils.BaseT + self.assertFalse(utils.validate_import_uri("http://10.1:80/")) + self.assertFalse(utils.validate_import_uri("http://192.168.1:80/")) + +- # Test with allowed host - encoded IP should still be rejected ++ # Test with allowed host - loopback may be allowed via whitelist + self.config(disallowed_hosts=[], + group='import_filtering_opts') + self.config(allowed_hosts=['127.0.0.1'], +@@ -1180,7 +1183,16 @@ class ImportURITestCase(test_utils.BaseT + self.assertFalse( + utils.validate_import_uri("http://[::1]:80/")) + +- # Test that IPv6 address not in blacklist is allowed ++ # Test that IPv6 localhost is blocked by default ++ self.config(disallowed_hosts=[], ++ group='import_filtering_opts') ++ self.config(allowed_hosts=[], ++ group='import_filtering_opts') ++ self.config(allowed_ports=[80], ++ group='import_filtering_opts') ++ self.assertFalse(utils.validate_import_uri("http://[::1]:80/")) ++ ++ # Test that IPv6 address not in blacklist is allowed when whitelisted + self.config(disallowed_hosts=[], + group='import_filtering_opts') + self.config(allowed_hosts=['2001:db8::1'], +@@ -1196,14 +1208,21 @@ class ImportURITestCase(test_utils.BaseT + group='import_filtering_opts') + self.config(allowed_ports=[80], + group='import_filtering_opts') +- # IPv6 localhost should pass if not in blacklist (no whitelist) +- # The fix ensures IPv6 is normalized and can be blacklisted separately ++ # IPv6 localhost is blocked by default even when only IPv4 is listed ++ # in disallowed_hosts + result = utils.validate_import_uri("http://[::1]:80/") +- # If ::1 is not in blacklist and no whitelist, it will pass +- # Administrators should add both IPv4 and IPv6 to blacklist if needed +- self.assertTrue(result) ++ self.assertFalse(result) + + # Test that IPv6 can be blacklisted separately + self.config(disallowed_hosts=['127.0.0.1', '::1'], + group='import_filtering_opts') + self.assertFalse(utils.validate_import_uri("http://[::1]:80/")) ++ ++ @mock.patch("glance.common.utils.socket.getaddrinfo") ++ def test_validate_import_uri_blocks_dns_rebinding(self, mock_getaddrinfo): ++ """Hostnames resolving to restricted addresses are rejected.""" ++ mock_getaddrinfo.return_value = [ ++ (socket.AF_INET, socket.SOCK_STREAM, 6, '', ++ ('169.254.169.254', 80)) ++ ] ++ self.assertFalse(utils.validate_import_uri("http://metadata.example/")) +Index: glance/releasenotes/notes/bug-2158998-a3c7d1e9f2b4a806.yaml +=================================================================== +--- /dev/null ++++ glance/releasenotes/notes/bug-2158998-a3c7d1e9f2b4a806.yaml +@@ -0,0 +1,23 @@ ++--- ++security: ++ - | ++ Fixed a Server-Side Request Forgery (SSRF) vulnerability in the ++ ``web-download`` image import method. With the default ++ ``import_filtering_opts`` configuration, authenticated users could ++ supply URIs targeting loopback or link-local addresses (for example ++ cloud metadata at ``169.254.169.254``) and read the response through ++ the imported image data. ++ ++ Glance now rejects import URIs that target loopback or link-local ++ IP addresses unless the host is explicitly listed in ++ ``allowed_hosts``. Hostnames that resolve to any of those address ++ types are also rejected. Private RFC1918 addresses are not blocked ++ by this check so private clouds can continue to import from ++ internal hosts. ++ ++fixes: ++ - | ++ `Bug 2158998 `_: ++ Block loopback and link-local destination addresses during ++ web-download URI validation to prevent SSRF against local services ++ and cloud metadata endpoints. diff -Nru glance-30.0.0/debian/patches/CVE-2026-71196-71197-71198_3_Pin_import_downloads_to_validated_destination_addresses.patch glance-30.0.0/debian/patches/CVE-2026-71196-71197-71198_3_Pin_import_downloads_to_validated_destination_addresses.patch --- glance-30.0.0/debian/patches/CVE-2026-71196-71197-71198_3_Pin_import_downloads_to_validated_destination_addresses.patch 1970-01-01 00:00:00.000000000 +0000 +++ glance-30.0.0/debian/patches/CVE-2026-71196-71197-71198_3_Pin_import_downloads_to_validated_destination_addresses.patch 2026-08-31 20:49:17.000000000 +0000 @@ -0,0 +1,1001 @@ +Author: Abhishek Kekane +Date: Thu, 23 Jul 2026 07:07:52 -0700 +Description: Pin import downloads to validated destination addresses + Re-validate import URIs at fetch time, resolve DNS when the download + starts, and pin HTTP connections to the validated destination address. + This closes the DNS rebinding window between API validation and async + worker download. + . + On stable/2025.1, get_image_data_iter continues to return a single + iterable via _size_limited_reader (not the (data, size) tuple used on + newer branches). FunctionalTest web-download fixtures use 127.0.0.1 so + the pin-download path matches the IPv4-only test HTTP server; a DNS mock + cannot reach the glance-api subprocess. SynchronousAPIBase patches + glance.common.utils.socket.getaddrinfo for in-process dual-stack hosts. +Bug: https://bugs.launchpad.net/glance/+bug/2158999 +Bug-Debian: https://bugs.debian.org/1146594 +Assisted-By: Cursor (claude-4.5-sonnet) +Change-Id: Ie4fbbf28262e4c5650b40798dde6007748cc9760 +Signed-off-by: Abhishek Kekane +Co-authored-by: Cursor +Origin: upstream, pre-OSSA mailing list +Last-Update: 2026-08-28 + +Index: glance/doc/source/admin/interoperable-image-import.rst +=================================================================== +--- glance.orig/doc/source/admin/interoperable-image-import.rst ++++ glance/doc/source/admin/interoperable-image-import.rst +@@ -242,6 +242,14 @@ Validation of a URI happens as follows: + + 4. The URI is accepted as valid. + ++When Glance later downloads the image data, the URI is validated again and ++DNS is resolved at fetch time. Every returned address is checked against the ++same host filtering rules, including ``disallowed_hosts`` entries that list ++IP addresses. The HTTP connection is pinned to the validated destination ++address so a DNS change between API validation and worker download cannot ++redirect the fetch to a different target. Redirect destinations are ++validated and pinned on each hop. ++ + Note that if you allow a scheme, either by whitelisting it or by not + blacklisting it, any URI that uses the default port for that scheme by not + including a port in the URI is allowed. If it does include a port in the URI, +Index: glance/glance/async_/flows/_internal_plugins/glance_download.py +=================================================================== +--- glance.orig/glance/async_/flows/_internal_plugins/glance_download.py ++++ glance/glance/async_/flows/_internal_plugins/glance_download.py +@@ -25,7 +25,6 @@ from glance.async_.flows._internal_plugi + from glance.async_ import utils + from glance.common import exception + from glance.common.scripts import utils as script_utils +-from glance.common import utils as common_utils + from glance.i18n import _, _LI, _LE + + LOG = logging.getLogger(__name__) +@@ -57,19 +56,11 @@ class _DownloadGlanceImage(base_download + self.glance_service_interface) + image_download_url = '%s/v2/images/%s/file' % ( + glance_endpoint, self.glance_image_id) +- if not common_utils.validate_import_uri(image_download_url): +- LOG.debug("Processed URI for glance-download does not pass " +- "filtering: %s", image_download_url) +- msg = (_("Processed URI for glance-download does not pass " +- "filtering: %s") % image_download_url) +- raise exception.ImportTaskError(msg) + LOG.info(_LI("Downloading glance image %s"), image_download_url) + token = self.context.auth_token + request = urllib.request.Request(image_download_url, + headers={'X-Auth-Token': token}) +- opener = urllib.request.build_opener( +- script_utils.SafeRedirectHandler) +- data = opener.open(request) ++ data = script_utils.open_external_uri(request) + except Exception as e: + with excutils.save_and_reraise_exception(): + LOG.error( +Index: glance/glance/async_/flows/api_image_import.py +=================================================================== +--- glance.orig/glance/async_/flows/api_image_import.py ++++ glance/glance/async_/flows/api_image_import.py +@@ -815,9 +815,7 @@ class _ImportMetadata(task.Task): + token = self.context.auth_token + request = urllib.request.Request(image_download_metadata_url, + headers={'X-Auth-Token': token}) +- opener = urllib.request.build_opener( +- script_utils.SafeRedirectHandler) +- with opener.open(request) as payload: ++ with script_utils.open_external_uri(request) as payload: + data = json.loads(payload.read().decode('utf-8')) + + if data.get('status') != 'active': +Index: glance/glance/async_/flows/ovf_process.py +=================================================================== +--- glance.orig/glance/async_/flows/ovf_process.py ++++ glance/glance/async_/flows/ovf_process.py +@@ -17,8 +17,6 @@ import os + import re + import shutil + import tarfile +-import urllib +-import urllib.request + + from defusedxml import ElementTree as etree + +@@ -28,9 +26,7 @@ from oslo_serialization import jsonutils + from taskflow.patterns import linear_flow as lf + from taskflow import task + +-from glance.common import exception + from glance.common.scripts import utils as script_utils +-from glance.common import utils as common_utils + from glance.i18n import _, _LW + + LOG = logging.getLogger(__name__) +@@ -79,13 +75,7 @@ class _OVF_Process(task.Task): + uri = uri.split("file://")[-1] + return open(uri, "rb") + +- if not common_utils.validate_import_uri(uri): +- msg = (_("URI for OVF processing does not pass filtering: %s") % +- uri) +- raise exception.ImportTaskError(msg) +- +- opener = urllib.request.build_opener(script_utils.SafeRedirectHandler) +- return opener.open(uri) ++ return script_utils.open_external_uri(uri) + + def execute(self, image_id, file_path): + """ +Index: glance/glance/common/scripts/utils.py +=================================================================== +--- glance.orig/glance/common/scripts/utils.py ++++ glance/glance/common/scripts/utils.py +@@ -20,9 +20,11 @@ __all__ = [ + 'validate_location_uri', + 'validate_legacy_import_from_uri', + 'get_image_data_iter', ++ 'open_external_uri', + 'SafeRedirectHandler', + ] + ++import http.client + import urllib + import urllib.error + import urllib.request +@@ -147,20 +149,105 @@ class SafeRedirectHandler(urllib.request + def redirect_request(self, req, fp, code, msg, headers, newurl): + if not common_utils.validate_import_uri(newurl): + msg = (_("Redirect to disallowed URL: %s") % newurl) +- raise exception.ImportTaskError(msg) ++ raise exception.InvalidRedirect(msg) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + ++class _PinnedHTTPConnection(http.client.HTTPConnection): ++ """HTTP connection that connects to a pre-validated IP address.""" ++ ++ def __init__(self, host, port=None, *, pinned_ip=None, **kwargs): ++ self._pinned_ip = pinned_ip ++ super().__init__(host, port=port, **kwargs) ++ ++ def connect(self): ++ if self._pinned_ip: ++ self.sock = self._create_connection( ++ (self._pinned_ip, self.port), self.timeout, ++ self.source_address) ++ return ++ super().connect() ++ ++ ++class _PinnedHTTPSConnection(http.client.HTTPSConnection): ++ """HTTPS connection that connects to a pre-validated IP address.""" ++ ++ def __init__(self, host, port=None, *, pinned_ip=None, **kwargs): ++ self._pinned_ip = pinned_ip ++ super().__init__(host, port=port, **kwargs) ++ ++ def connect(self): ++ if self._pinned_ip: ++ sock = self._create_connection( ++ (self._pinned_ip, self.port), self.timeout, ++ self.source_address) ++ if self._tunnel_host: ++ self.sock = sock ++ self._tunnel() ++ else: ++ self.sock = self._context.wrap_socket( ++ sock, server_hostname=self.host) ++ return ++ super().connect() ++ ++ ++def _pinned_ip_for_request(req): ++ """Return a pinned destination IP for an external HTTP(S) URI.""" ++ try: ++ return common_utils.get_validated_import_address(req.full_url) ++ except ValueError as exc: ++ msg = (_("URI does not pass filtering: %s") % req.full_url) ++ LOG.debug("%s (%s)", msg, exc) ++ raise exception.Invalid(msg) ++ ++ ++class _ValidatedExternalHTTPHandler(urllib.request.HTTPHandler): ++ """Open HTTP URIs using validated, pinned destination addresses.""" ++ ++ def http_open(self, req): ++ pinned_ip = _pinned_ip_for_request(req) ++ return self.do_open( ++ lambda host, **kwargs: _PinnedHTTPConnection( ++ host, pinned_ip=pinned_ip, **kwargs), ++ req) ++ ++ ++class _ValidatedExternalHTTPSHandler(urllib.request.HTTPSHandler): ++ """Open HTTPS URIs using validated, pinned destination addresses.""" ++ ++ def https_open(self, req): ++ pinned_ip = _pinned_ip_for_request(req) ++ return self.do_open( ++ lambda host, **kwargs: _PinnedHTTPSConnection( ++ host, pinned_ip=pinned_ip, **kwargs), ++ req) ++ ++ ++def open_external_uri(uri_or_request): ++ """Open an external URL string or Request with validation and IP pinning. ++ ++ The destination is re-checked at fetch time and the TCP connection is ++ pinned to the validated address so DNS cannot rebind mid-download. ++ Suitable for import downloads and other outbound HTTP(S) fetches that ++ need the same host filtering protections. ++ """ ++ opener = urllib.request.build_opener( ++ SafeRedirectHandler, ++ _ValidatedExternalHTTPHandler, ++ _ValidatedExternalHTTPSHandler, ++ ) ++ return opener.open(uri_or_request) ++ ++ + def get_image_data_iter(uri): + """Returns iterable object either for local file or uri + + :param uri: uri (remote or local) to the datasource we want to iterate + +- Validation/sanitization of the uri is expected to happen before we get +- here. ++ Remote HTTP(S) URIs are validated and resolved at fetch time. The ++ connection is pinned to the validated destination address to close the ++ DNS rebinding window between API validation and worker download. + """ +- # NOTE(flaper87): This is safe because the input uri is already +- # verified before the task is created. + if uri.startswith("file://"): + uri = uri.split("file://")[-1] + # NOTE(flaper87): The caller of this function expects to have +@@ -175,8 +262,7 @@ def get_image_data_iter(uri): + data = open(uri, "rb") + return _size_limited_reader(data) + +- opener = urllib.request.build_opener(SafeRedirectHandler) +- return _size_limited_reader(opener.open(uri)) ++ return _size_limited_reader(open_external_uri(uri)) + + + def _size_limited_reader(data): +Index: glance/glance/common/utils.py +=================================================================== +--- glance.orig/glance/common/utils.py ++++ glance/glance/common/utils.py +@@ -186,51 +186,93 @@ def normalize_hostname(host): + return host + + +-def _is_restricted_import_host(normalized_host): +- """Return True if host is or resolves to a restricted address. ++def default_import_port(scheme): ++ """Return the default port for an import URI scheme.""" ++ return 443 if scheme == 'https' else 80 ++ ++ ++def resolve_pinned_import_address(hostname, port): ++ """Resolve hostname and return the first import-allowed IP address. + + Blocks loopback and link-local addresses (including cloud metadata +- endpoints such as 169.254.169.254). Private RFC1918 ranges are not +- blocked so private clouds can import from internal hosts. IPv4-mapped +- IPv6 addresses are checked against the embedded IPv4 address. For +- hostnames, any resolved address that is restricted causes rejection +- (fail closed on DNS errors). ++ endpoints such as 169.254.169.254) unless the host is listed in ++ allowed_hosts. Private RFC1918 ranges are not blocked so private ++ clouds can import from internal hosts. IPv4-mapped IPv6 addresses ++ are checked against the embedded IPv4 address. Matching ++ disallowed_hosts IP entries also reject resolved addresses. ++ ++ :param hostname: hostname or IP from the import URI ++ :param port: destination port from the import URI ++ :returns: IP address string to connect to ++ :raises ValueError: if the host cannot be used for import + """ ++ normalized_host = normalize_hostname(hostname) ++ if not normalized_host: ++ raise ValueError('invalid import host: %s' % hostname) ++ ++ bl_hosts = list(CONF.import_filtering_opts.disallowed_hosts) ++ wl_hosts = CONF.import_filtering_opts.allowed_hosts ++ if wl_hosts and bl_hosts: ++ bl_hosts = [] ++ host_is_whitelisted = bool(wl_hosts and normalized_host in wl_hosts) ++ + try: +- addresses = [ipaddress.ip_address(normalized_host)] +- except ValueError: +- testhost = normalized_host if normalized_host.endswith('.') else ( +- normalized_host + '.') + try: ++ addresses = [ipaddress.ip_address(normalized_host)] ++ except ValueError: ++ testhost = (normalized_host if normalized_host.endswith('.') ++ else normalized_host + '.') + addresses = [] +- for result in socket.getaddrinfo(testhost, None): ++ for result in socket.getaddrinfo(testhost, port): + ip = result[4][0] + if not ip: +- return True ++ raise ValueError('empty address in getaddrinfo result') + if '%' in ip: + ip = ip.split('%', 1)[0] +- try: +- addresses.append(ipaddress.ip_address(ip)) +- except ValueError: +- return True +- except socket.gaierror: +- return True ++ addr = ipaddress.ip_address(ip) ++ if addr not in addresses: ++ addresses.append(addr) ++ except (socket.gaierror, ValueError) as exc: ++ raise ValueError('failed to resolve import host %s: %s' % ++ (hostname, exc)) + + for addr in addresses: ++ if host_is_whitelisted: ++ return str(addr) ++ ++ check = addr + if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped: +- addr = addr.ipv4_mapped +- if addr.is_loopback or addr.is_link_local: +- return True +- return False ++ check = addr.ipv4_mapped ++ if check.is_loopback or check.is_link_local: ++ continue ++ ++ addr_str = str(addr) ++ blocked = False ++ for blocked_host in bl_hosts: ++ if blocked_host == normalized_host: ++ continue ++ try: ++ if addr == ipaddress.ip_address(blocked_host): ++ blocked = True ++ break ++ except ValueError: ++ if addr_str == blocked_host: ++ blocked = True ++ break ++ if not blocked: ++ return str(addr) + ++ raise ValueError('no allowed addresses for import host: %s' % hostname) + +-def validate_import_uri(uri): +- """Validate requested uri for Image Import web-download. + +- :param uri: target uri to be validated ++def get_validated_import_address(uri): ++ """Validate an import URI and return a pinned destination IP. ++ ++ Applies scheme/host/port filtering, resolves DNS, and returns the first ++ allowed address. Raises ValueError if the URI must not be fetched. + """ + if not uri: +- return False ++ raise ValueError('empty import URI') + + parsed_uri = urllib.parse.urlparse(uri) + scheme = parsed_uri.scheme +@@ -260,25 +302,29 @@ def validate_import_uri(uri): + + if not scheme or ((wl_schemes and scheme not in wl_schemes) or + parsed_uri.scheme in bl_schemes): +- return False ++ raise ValueError('scheme not allowed for import URI: %s' % uri) + + normalized_host = normalize_hostname(host) +- + if not normalized_host or ( + (wl_hosts and normalized_host not in wl_hosts) or + normalized_host in bl_hosts): +- return False +- +- host_is_whitelisted = bool(wl_hosts and normalized_host in wl_hosts) +- if not host_is_whitelisted and _is_restricted_import_host( +- normalized_host): +- return False ++ raise ValueError('host not allowed for import URI: %s' % uri) + + if port and ((wl_ports and port not in wl_ports) or + port in bl_ports): +- return False ++ raise ValueError('port not allowed for import URI: %s' % uri) + +- return True ++ resolve_port = port or default_import_port(scheme) ++ return resolve_pinned_import_address(normalized_host, resolve_port) ++ ++ ++def validate_import_uri(uri): ++ """Return True if the URI passes image-import filtering.""" ++ try: ++ get_validated_import_address(uri) ++ return True ++ except ValueError: ++ return False + + + class CooperativeReader(object): +Index: glance/glance/tests/functional/__init__.py +=================================================================== +--- glance.orig/glance/tests/functional/__init__.py ++++ glance/glance/tests/functional/__init__.py +@@ -1536,6 +1536,29 @@ class SynchronousAPIBase(test_utils.Base + self.setup_simple_paste() + self.setup_stores() + ++ def _mock_localhost_dns(self): ++ """Force localhost to resolve to 127.0.0.1 only. ++ ++ Test HTTP servers bind to 127.0.0.1. On hosts where getaddrinfo ++ returns ::1 first, DNS-pinned connections fail with ECONNREFUSED. ++ """ ++ # Patch the socket used by glance.common.utils. After eventlet ++ # monkey-patching, mock.patch('socket.getaddrinfo') does not affect ++ # the pin-download path. ++ real_getaddrinfo = utils.socket.getaddrinfo ++ ++ def _ipv4_only(host, port, *args, **kwargs): ++ results = real_getaddrinfo(host, port, *args, **kwargs) ++ if host in ('localhost', 'localhost.'): ++ results = [r for r in results if r[0] == socket.AF_INET] ++ return results ++ ++ patcher = mock.patch( ++ 'glance.common.utils.socket.getaddrinfo', ++ side_effect=_ipv4_only) ++ patcher.start() ++ self.addCleanup(patcher.stop) ++ + def start_server(self, enable_cache=True, set_worker_url=True): + """Builds and "starts" the API server. + +Index: glance/glance/tests/functional/v2/test_images.py +=================================================================== +--- glance.orig/glance/tests/functional/v2/test_images.py ++++ glance/glance/tests/functional/v2/test_images.py +@@ -244,7 +244,7 @@ class TestImages(functional.FunctionalTe + self.stop_servers() + + def test_image_import_using_web_download(self): +- self.allowed_hosts = ['localhost'] ++ self.allowed_hosts = ['127.0.0.1', 'localhost'] + self.start_servers(**self.__dict__.copy()) + + # Image list should be empty +@@ -339,7 +339,7 @@ class TestImages(functional.FunctionalTe + # Start http server locally + thread, httpd, port = test_utils.start_standalone_http_server() + +- image_data_uri = 'http://localhost:%s/' % port ++ image_data_uri = 'http://127.0.0.1:%s/' % port + data = jsonutils.dumps({'method': { + 'name': 'web-download', + 'uri': image_data_uri +@@ -389,6 +389,7 @@ class TestImages(functional.FunctionalTe + def test_web_download_redirect_validation(self): + """Test that redirect destinations are validated.""" + self.config(allowed_ports=[80], group='import_filtering_opts') ++ # Keep 127.0.0.1 off the whitelist so the redirect hop is rejected. + self.allowed_hosts = ['localhost'] + self.config(disallowed_hosts=['127.0.0.1'], + group='import_filtering_opts') +@@ -5431,7 +5432,7 @@ class TestImagesMultipleBackend(function + self.stop_servers() + + def test_image_import_using_web_download(self): +- self.allowed_hosts = ['localhost'] ++ self.allowed_hosts = ['127.0.0.1', 'localhost'] + self.start_servers(**self.__dict__.copy()) + + # Image list should be empty +@@ -5543,7 +5544,7 @@ class TestImagesMultipleBackend(function + # Start http server locally + thread, httpd, port = test_utils.start_standalone_http_server() + +- image_data_uri = 'http://localhost:%s/' % port ++ image_data_uri = 'http://127.0.0.1:%s/' % port + data = jsonutils.dumps({'method': { + 'name': 'web-download', + 'uri': image_data_uri +@@ -5596,7 +5597,7 @@ class TestImagesMultipleBackend(function + self.stop_servers() + + def test_image_import_using_web_download_different_backend(self): +- self.allowed_hosts = ['localhost'] ++ self.allowed_hosts = ['127.0.0.1', 'localhost'] + self.start_servers(**self.__dict__.copy()) + + # Image list should be empty +@@ -5708,7 +5709,7 @@ class TestImagesMultipleBackend(function + # Start http server locally + thread, httpd, port = test_utils.start_standalone_http_server() + +- image_data_uri = 'http://localhost:%s/' % port ++ image_data_uri = 'http://127.0.0.1:%s/' % port + data = jsonutils.dumps({'method': { + 'name': 'web-download', + 'uri': image_data_uri +@@ -5762,7 +5763,7 @@ class TestImagesMultipleBackend(function + self.stop_servers() + + def test_image_import_multi_stores(self): +- self.allowed_hosts = ['localhost'] ++ self.allowed_hosts = ['127.0.0.1', 'localhost'] + self.start_servers(**self.__dict__.copy()) + + # Image list should be empty +@@ -5873,7 +5874,7 @@ class TestImagesMultipleBackend(function + # Start http server locally + thread, httpd, port = test_utils.start_standalone_http_server() + +- image_data_uri = 'http://localhost:%s/' % port ++ image_data_uri = 'http://127.0.0.1:%s/' % port + data = jsonutils.dumps( + {'method': {'name': 'web-download', 'uri': image_data_uri}, + 'stores': ['file1', 'file2']}) +@@ -5927,7 +5928,7 @@ class TestImagesMultipleBackend(function + self.stop_servers() + + def test_copy_image_lifecycle(self): +- self.allowed_hosts = ['localhost'] ++ self.allowed_hosts = ['127.0.0.1', 'localhost'] + self.start_servers(**self.__dict__.copy()) + + # Image list should be empty +@@ -6038,7 +6039,7 @@ class TestImagesMultipleBackend(function + # Start http server locally + thread, httpd, port = test_utils.start_standalone_http_server() + +- image_data_uri = 'http://localhost:%s/' % port ++ image_data_uri = 'http://127.0.0.1:%s/' % port + data = jsonutils.dumps( + {'method': {'name': 'web-download', 'uri': image_data_uri}, + 'stores': ['file1']}) +@@ -6154,7 +6155,7 @@ class TestImagesMultipleBackend(function + # Test if copying task fails in between then the rollback + # should delete the data from only stores to which it is + # copied and not from the existing stores. +- self.allowed_hosts = ['localhost'] ++ self.allowed_hosts = ['127.0.0.1', 'localhost'] + self.start_servers(**self.__dict__.copy()) + + # Image list should be empty +@@ -6265,7 +6266,7 @@ class TestImagesMultipleBackend(function + # Start http server locally + thread, httpd, port = test_utils.start_standalone_http_server() + +- image_data_uri = 'http://localhost:%s/' % port ++ image_data_uri = 'http://127.0.0.1:%s/' % port + data = jsonutils.dumps( + {'method': {'name': 'web-download', 'uri': image_data_uri}, + 'stores': ['file1']}) +@@ -6414,7 +6415,7 @@ class TestImagesMultipleBackend(function + self.stop_servers() + + def test_image_import_multi_stores_specifying_all_stores(self): +- self.allowed_hosts = ['localhost'] ++ self.allowed_hosts = ['127.0.0.1', 'localhost'] + self.start_servers(**self.__dict__.copy()) + + # Image list should be empty +@@ -6525,7 +6526,7 @@ class TestImagesMultipleBackend(function + # Start http server locally + thread, httpd, port = test_utils.start_standalone_http_server() + +- image_data_uri = 'http://localhost:%s/' % port ++ image_data_uri = 'http://127.0.0.1:%s/' % port + data = jsonutils.dumps( + {'method': {'name': 'web-download', 'uri': image_data_uri}, + 'all_stores': True}) +@@ -7380,7 +7381,7 @@ class TestCopyImagePermissions(functiona + # Start http server locally + thread, httpd, port = test_utils.start_standalone_http_server() + +- image_data_uri = 'http://localhost:%s/' % port ++ image_data_uri = 'http://127.0.0.1:%s/' % port + data = jsonutils.dumps( + {'method': {'name': 'web-download', 'uri': image_data_uri}, + 'stores': ['file1']}) +@@ -7415,7 +7416,7 @@ class TestCopyImagePermissions(functiona + return image_id + + def _test_copy_public_image_as_non_admin(self): +- self.allowed_hosts = ['localhost'] ++ self.allowed_hosts = ['127.0.0.1', 'localhost'] + self.start_servers(**self.__dict__.copy()) + + # Create a publicly-visible image as TENANT1 +Index: glance/glance/tests/unit/async_/flows/test_api_image_import.py +=================================================================== +--- glance.orig/glance/tests/unit/async_/flows/test_api_image_import.py ++++ glance/glance/tests/unit/async_/flows/test_api_image_import.py +@@ -25,7 +25,6 @@ import taskflow + import glance.async_.flows.api_image_import as import_flow + from glance.common import exception + from glance.common.scripts.image_import import main as image_import +-from glance.common.scripts import utils as script_utils + from glance import context + from glance.domain import ExtraProperties + from glance import gateway +@@ -1292,41 +1291,27 @@ class TestImportMetadata(test_utils.Base + 'os_hash': 'hash' + }) + +- @mock.patch('urllib.request') ++ @mock.patch('glance.common.scripts.utils.open_external_uri') + @mock.patch('glance.async_.utils.get_glance_endpoint') + def test_import_metadata_redirect_validation(self, mock_gge, +- mock_request): ++ mock_open_external_uri): + """Test redirect destinations are validated during metadata fetch.""" + mock_gge.return_value = 'https://other.cloud.foo/image' + task = import_flow._ImportMetadata(TASK_ID1, TASK_TYPE, + self.context, self.wrapper, + self.import_req) +- mock_opener = mock.MagicMock() +- # Simulate redirect to disallowed URL +- mock_opener.open.side_effect = exception.ImportTaskError( ++ mock_open_external_uri.side_effect = exception.InvalidRedirect( + "Redirect to disallowed URL: http://127.0.0.1:5000/") +- mock_request.build_opener.return_value = mock_opener +- self.assertRaises(exception.ImportTaskError, task.execute) +- # Verify SafeRedirectHandler is used +- mock_request.build_opener.assert_called_once() +- # Verify the handler passed is SafeRedirectHandler +- call_args = mock_request.build_opener.call_args +- # Check if SafeRedirectHandler class or instance is in args +- found_handler = ( +- any(isinstance(arg, script_utils.SafeRedirectHandler) +- for arg in call_args.args) or +- script_utils.SafeRedirectHandler in call_args.args) +- self.assertTrue( +- found_handler, +- "SafeRedirectHandler should be used for redirect validation") ++ self.assertRaises(exception.InvalidRedirect, task.execute) ++ mock_open_external_uri.assert_called_once() + +- @mock.patch('urllib.request') ++ @mock.patch('glance.common.scripts.utils.open_external_uri') + @mock.patch('glance.async_.flows.api_image_import.json') + @mock.patch('glance.async_.utils.get_glance_endpoint') +- def test_import_metadata_uses_safe_redirect_handler(self, mock_gge, +- mock_json, +- mock_request): +- """Test that SafeRedirectHandler is used and allows valid redirects.""" ++ def test_import_metadata_uses_external_uri_opener(self, mock_gge, ++ mock_json, ++ mock_open_external_uri): ++ """Test metadata fetch uses open_external_uri for HTTP(S).""" + mock_gge.return_value = 'https://other.cloud.foo/image' + mock_json.loads.return_value = { + 'status': 'active', +@@ -1334,27 +1319,15 @@ class TestImportMetadata(test_utils.Base + 'container_format': 'bare', + 'size': '12345' + } +- mock_opener = mock.MagicMock() + mock_payload = mock.MagicMock() + mock_payload.read.return_value = b'{"status": "active"}' +- mock_opener.open.return_value.__enter__.return_value = mock_payload +- mock_request.build_opener.return_value = mock_opener ++ mock_open_external_uri.return_value.__enter__.return_value = ( ++ mock_payload) + task = import_flow._ImportMetadata(TASK_ID1, TASK_TYPE, + self.context, self.wrapper, + self.import_req) +- # Execute should succeed with valid redirect + result = task.execute() +- # Verify build_opener was called with SafeRedirectHandler +- mock_request.build_opener.assert_called_once() +- call_args = mock_request.build_opener.call_args +- found_handler = ( +- any(isinstance(arg, script_utils.SafeRedirectHandler) +- for arg in call_args.args) or +- script_utils.SafeRedirectHandler in call_args.args) +- self.assertTrue( +- found_handler, +- "SafeRedirectHandler should be passed to build_opener") +- # Verify execution succeeded (handler allows valid redirects) ++ mock_open_external_uri.assert_called_once() + self.assertEqual(12345, result) + + def test_revert_rollback_metadata_value(self): +Index: glance/glance/tests/unit/async_/flows/test_glance_download.py +=================================================================== +--- glance.orig/glance/tests/unit/async_/flows/test_glance_download.py ++++ glance/glance/tests/unit/async_/flows/test_glance_download.py +@@ -13,6 +13,7 @@ + # License for the specific language governing permissions and limitations + # under the License. + ++import socket + from unittest import mock + import urllib.error + +@@ -23,7 +24,6 @@ from oslo_utils.fixture import uuidsenti + from glance.async_.flows._internal_plugins import glance_download + from glance.async_.flows import api_image_import + from glance.common import exception +-from glance.common.scripts import utils as script_utils + import glance.context + from glance import domain + import glance.tests.utils as test_utils +@@ -171,89 +171,62 @@ class TestGlanceDownloadTask(test_utils. + headers={'X-Auth-Token': self.context.auth_token}) + mock_gge.assert_called_once_with(self.context, 'RegionTwo', 'public') + +- @mock.patch('urllib.request') +- @mock.patch('glance.common.utils.validate_import_uri') ++ @mock.patch('glance.common.scripts.utils.open_external_uri') + @mock.patch('glance.async_.utils.get_glance_endpoint') +- def test_glance_download_wrong_download_url(self, mock_gge, mock_validate, +- mock_request): +- mock_validate.return_value = False ++ def test_glance_download_wrong_download_url(self, mock_gge, ++ mock_open_external_uri): ++ mock_open_external_uri.side_effect = exception.Invalid( ++ 'URI does not pass filtering') + mock_gge.return_value = 'https://other.cloud.foo/image' + glance_download_task = glance_download._DownloadGlanceImage( + self.context, self.task.task_id, self.task_type, + self.action_wrapper, ['foo'], + 'RegionTwo', uuidsentinel.remote_image, 'public') +- self.assertRaises(glance.common.exception.ImportTaskError, ++ self.assertRaises(exception.Invalid, + glance_download_task.execute, 12345) +- mock_request.assert_not_called() +- mock_validate.assert_called_once_with( +- 'https://other.cloud.foo/image/v2/images/%s/file' % ( +- uuidsentinel.remote_image)) ++ mock_open_external_uri.assert_called_once() + + @mock.patch('glance.common.utils.socket.getaddrinfo') +- @mock.patch('urllib.request') ++ @mock.patch('glance.common.scripts.utils.open_external_uri') + @mock.patch('glance.async_.utils.get_glance_endpoint') + def test_glance_download_redirect_validation(self, mock_gge, +- mock_request, ++ mock_open_external_uri, + mock_getaddrinfo): + """Test redirect destinations are validated during image download.""" + mock_getaddrinfo.return_value = [ +- ('', '', '', '', ('93.184.216.34', 80))] ++ (socket.AF_INET, socket.SOCK_STREAM, 6, '', ++ ('93.184.216.34', 80))] + mock_gge.return_value = 'https://other.cloud.foo/image' + glance_download_task = glance_download._DownloadGlanceImage( + self.context, self.task.task_id, self.task_type, + self.action_wrapper, ['foo'], + 'RegionTwo', uuidsentinel.remote_image, 'public') +- mock_opener = mock.MagicMock() +- # Simulate redirect to disallowed URL +- mock_opener.open.side_effect = exception.ImportTaskError( ++ # Simulate redirect to disallowed URL rejected by external opener ++ mock_open_external_uri.side_effect = exception.InvalidRedirect( + "Redirect to disallowed URL: http://127.0.0.1:5000/") +- mock_request.build_opener.return_value = mock_opener +- self.assertRaises(exception.ImportTaskError, ++ self.assertRaises(exception.InvalidRedirect, + glance_download_task.execute, 12345) +- # Verify SafeRedirectHandler is used +- mock_request.build_opener.assert_called_once() +- # Verify the handler passed is SafeRedirectHandler +- call_args = mock_request.build_opener.call_args +- # Check if SafeRedirectHandler class or instance is in args +- found_handler = ( +- any(isinstance(arg, script_utils.SafeRedirectHandler) +- for arg in call_args.args) or +- script_utils.SafeRedirectHandler in call_args.args) +- self.assertTrue( +- found_handler, +- "SafeRedirectHandler should be used for redirect validation") ++ mock_open_external_uri.assert_called_once() + + @mock.patch('glance.common.utils.socket.getaddrinfo') + @mock.patch.object(filesystem.Store, 'add') +- @mock.patch('urllib.request') ++ @mock.patch('glance.common.scripts.utils.open_external_uri') + @mock.patch('glance.async_.utils.get_glance_endpoint') +- def test_glance_download_uses_safe_redirect_handler( +- self, mock_gge, mock_request, mock_add, mock_getaddrinfo): +- """Test that SafeRedirectHandler is used and allows valid execution.""" ++ def test_glance_download_uses_external_uri_opener( ++ self, mock_gge, mock_open_external_uri, mock_add, ++ mock_getaddrinfo): ++ """Test that glance-download uses open_external_uri for fetches.""" + mock_getaddrinfo.return_value = [ +- ('', '', '', '', ('93.184.216.34', 80))] ++ (socket.AF_INET, socket.SOCK_STREAM, 6, '', ++ ('93.184.216.34', 80))] + mock_gge.return_value = 'https://other.cloud.foo/image' + glance_download_task = glance_download._DownloadGlanceImage( + self.context, self.task.task_id, self.task_type, + self.action_wrapper, ['foo'], + 'RegionTwo', uuidsentinel.remote_image, 'public') +- mock_opener = mock.MagicMock() + mock_response = mock.MagicMock() +- mock_opener.open.return_value = mock_response +- mock_request.build_opener.return_value = mock_opener ++ mock_open_external_uri.return_value = mock_response + mock_add.return_value = ["path", 12345] + result = glance_download_task.execute(12345) +- # Verify build_opener was called with SafeRedirectHandler +- mock_request.build_opener.assert_called_once() +- # Verify SafeRedirectHandler was passed +- call_args = mock_request.build_opener.call_args +- # Check if SafeRedirectHandler class or instance is in args +- found_handler = ( +- any(isinstance(arg, script_utils.SafeRedirectHandler) +- for arg in call_args.args) or +- script_utils.SafeRedirectHandler in call_args.args) +- self.assertTrue( +- found_handler, +- "SafeRedirectHandler should be passed to build_opener") +- # Verify execution succeeded (handler allows valid execution) ++ mock_open_external_uri.assert_called_once() + self.assertEqual("path", result) +Index: glance/glance/tests/unit/async_/flows/test_ovf_process.py +=================================================================== +--- glance.orig/glance/tests/unit/async_/flows/test_ovf_process.py ++++ glance/glance/tests/unit/async_/flows/test_ovf_process.py +@@ -18,7 +18,6 @@ import shutil + import tarfile + import tempfile + from unittest import mock +-import urllib.error + + from defusedxml.ElementTree import ParseError + +@@ -167,55 +166,38 @@ class TestOvfProcessTask(test_utils.Base + with open(ova_file_path, 'rb') as ova_file: + self.assertRaises(ParseError, iextractor._parse_OVF, ova_file) + +- @mock.patch('glance.common.utils.validate_import_uri') +- def test_get_ova_iter_objects_uri_validation_fails(self, mock_validate): +- """Test that disallowed URIs raise ImportTaskError""" +- mock_validate.return_value = False ++ @mock.patch('glance.common.scripts.utils.open_external_uri') ++ def test_get_ova_iter_objects_uri_validation_fails(self, mock_open): ++ """Test that disallowed URIs raise Invalid""" ++ mock_open.side_effect = exception.Invalid( ++ 'URI does not pass filtering') + oprocess = ovf_process._OVF_Process('task_id', 'ovf_proc', + self.img_repo) +- self.assertRaises(exception.ImportTaskError, ++ self.assertRaises(exception.Invalid, + oprocess._get_ova_iter_objects, + 'http://127.0.0.1:5000/package.ova') +- mock_validate.assert_called_once_with( +- 'http://127.0.0.1:5000/package.ova') ++ mock_open.assert_called_once_with('http://127.0.0.1:5000/package.ova') + +- @mock.patch('urllib.request') +- @mock.patch('glance.common.utils.validate_import_uri') +- def test_get_ova_iter_objects_uri_validation_passes(self, mock_validate, +- mock_request): +- """Test that allowed URIs use SafeRedirectHandler""" +- mock_validate.return_value = True +- mock_opener = mock.MagicMock() ++ @mock.patch('glance.common.scripts.utils.open_external_uri') ++ def test_get_ova_iter_objects_uri_validation_passes(self, mock_open): ++ """Test that allowed URIs use open_external_uri""" + mock_response = mock.MagicMock() +- mock_opener.open.return_value = mock_response +- mock_request.build_opener.return_value = mock_opener ++ mock_open.return_value = mock_response + oprocess = ovf_process._OVF_Process('task_id', 'ovf_proc', + self.img_repo) + result = oprocess._get_ova_iter_objects( + 'http://example.com/package.ova') + self.assertEqual(mock_response, result) +- mock_validate.assert_called_once_with( +- 'http://example.com/package.ova') +- mock_request.build_opener.assert_called_once() ++ mock_open.assert_called_once_with('http://example.com/package.ova') + +- @mock.patch('urllib.request') +- @mock.patch('glance.common.utils.validate_import_uri') +- def test_get_ova_iter_objects_redirect_validation(self, mock_validate, +- mock_request): ++ @mock.patch('glance.common.scripts.utils.open_external_uri') ++ def test_get_ova_iter_objects_redirect_validation(self, mock_open): + """Test that redirects to disallowed URLs are blocked""" +- # First call (initial URL) passes validation +- # Second call (redirect destination) fails validation +- mock_validate.side_effect = [True, False] +- mock_opener = mock.MagicMock() +- # Simulate redirect to disallowed URL +- mock_opener.open.side_effect = urllib.error.URLError( ++ mock_open.side_effect = exception.InvalidRedirect( + "Redirect to disallowed URL: http://127.0.0.1:5000/package.ova") +- mock_request.build_opener.return_value = mock_opener + oprocess = ovf_process._OVF_Process('task_id', 'ovf_proc', + self.img_repo) +- self.assertRaises(urllib.error.URLError, ++ self.assertRaises(exception.InvalidRedirect, + oprocess._get_ova_iter_objects, + 'http://example.com/package.ova') +- mock_validate.assert_called_once_with( +- 'http://example.com/package.ova') +- mock_request.build_opener.assert_called_once() ++ mock_open.assert_called_once_with('http://example.com/package.ova') +Index: glance/glance/tests/unit/common/scripts/test_scripts_utils.py +=================================================================== +--- glance.orig/glance/tests/unit/common/scripts/test_scripts_utils.py ++++ glance/glance/tests/unit/common/scripts/test_scripts_utils.py +@@ -297,9 +297,9 @@ class TestSafeRedirectHandler(test_utils + fp = mock.Mock() + headers = mock.Mock() + +- # Redirect to disallowed URL should raise ImportTaskError ++ # Redirect to disallowed URL should raise InvalidRedirect + self.assertRaises( +- exception.ImportTaskError, ++ exception.InvalidRedirect, + handler.redirect_request, + req, fp, 302, 'Found', headers, 'http://127.0.0.1:5000/' + ) +Index: glance/glance/tests/unit/common/test_utils.py +=================================================================== +--- glance.orig/glance/tests/unit/common/test_utils.py ++++ glance/glance/tests/unit/common/test_utils.py +@@ -952,6 +952,29 @@ class EvaluateFilterOpTestCase(test_util + self.assertRaises(exception.InvalidFilterOperatorValue, + utils.evaluate_filter_op, '10', 'bar', '8') + ++ @mock.patch("glance.common.utils.socket.getaddrinfo") ++ def test_validate_import_uri_blocks_disallowed_resolved_ip( ++ self, mock_getaddrinfo): ++ """Hostnames resolving to disallowed public IPs are rejected.""" ++ mock_getaddrinfo.return_value = [ ++ (socket.AF_INET, socket.SOCK_STREAM, 6, '', ++ ('93.184.216.34', 80)) ++ ] ++ self.config(disallowed_hosts=['93.184.216.34'], ++ group='import_filtering_opts') ++ self.config(allowed_ports=[80], group='import_filtering_opts') ++ self.assertFalse(utils.validate_import_uri("http://blocked.example/")) ++ ++ @mock.patch("glance.common.utils.socket.getaddrinfo") ++ def test_resolve_pinned_import_address(self, mock_getaddrinfo): ++ """Pinned address comes from validated DNS resolution.""" ++ mock_getaddrinfo.return_value = [ ++ (socket.AF_INET, socket.SOCK_STREAM, 6, '', ++ ('93.184.216.34', 80)) ++ ] ++ pinned = utils.resolve_pinned_import_address('example.com', 80) ++ self.assertEqual(pinned, '93.184.216.34') ++ + + class ImportURITestCase(test_utils.BaseTestCase): + +Index: glance/glance/tests/utils.py +=================================================================== +--- glance.orig/glance/tests/utils.py ++++ glance/glance/tests/utils.py +@@ -682,6 +682,12 @@ def db_sync(version='heads', engine=None + + + def start_standalone_http_server(): ++ """Serve fixture image data on an ephemeral IPv4 port. ++ ++ The server binds 127.0.0.1 only. Web-download tests must use that ++ address in the import URI so pin-download does not connect to ::1 on ++ dual-stack hosts. ++ """ + def _get_http_handler_class(): + class StaticHTTPRequestHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): diff -Nru glance-30.0.0/debian/patches/CVE-2026-71196-71197-71198_4_Block_restricted_hosts_when_adding_HTTP_image_locations.patch glance-30.0.0/debian/patches/CVE-2026-71196-71197-71198_4_Block_restricted_hosts_when_adding_HTTP_image_locations.patch --- glance-30.0.0/debian/patches/CVE-2026-71196-71197-71198_4_Block_restricted_hosts_when_adding_HTTP_image_locations.patch 1970-01-01 00:00:00.000000000 +0000 +++ glance-30.0.0/debian/patches/CVE-2026-71196-71197-71198_4_Block_restricted_hosts_when_adding_HTTP_image_locations.patch 2026-08-31 20:49:17.000000000 +0000 @@ -0,0 +1,792 @@ +AUthor: Abhishek Kekane +Date: Wed, 22 Jul 2026 07:41:29 +0000 +Description: Block restricted hosts when adding HTTP image locations + Apply import_filtering_opts to HTTP(S) locations and validate + the URI before contacting the backend, so location add cannot + SSRF glance-api into loopback or link-local targets. + . + On stable/2025.1, FunctionalTest location fixtures whitelist + 127.0.0.1/localhost via self.allowed_hosts because self.config() + does not affect the API subprocess. +Bug: https://bugs.launchpad.net/glance/+bug/2161330 +Bug-Debian: https://bugs.debian.org/1146594 +Assisted-By: Cursor (claude-4.5-sonnet) +Change-Id: Iff9793480ad6da44490afc6c686771bd85908a5a +Signed-off-by: Abhishek Kekane +Origin: upstream, pre-OSSA mailing list +Last-Update: 2026-08-28 + +Index: glance/doc/source/admin/interoperable-image-import.rst +=================================================================== +--- glance.orig/doc/source/admin/interoperable-image-import.rst ++++ glance/doc/source/admin/interoperable-image-import.rst +@@ -192,6 +192,12 @@ You can do this by configuring options i + ``[import_filtering_opts]`` section of the **glance-image-import.conf** file. + + .. note:: ++ The same ``import_filtering_opts`` host filtering also applies to HTTP(S) ++ image locations added via ``POST /v2/images/{image_id}/locations`` or the ++ legacy PATCH locations API. Operators who enable the HTTP store should ++ configure these options for location adds as well as web-download. ++ ++.. note:: + The **glance-image-import.conf** is an optional file. (See below for a + discussion of the default settings if you don't include this file.) + +Index: glance/glance/api/v2/images.py +=================================================================== +--- glance.orig/glance/api/v2/images.py ++++ glance/glance/api/v2/images.py +@@ -453,7 +453,7 @@ class ImagesController(object): + ctxt, admin_context=admin_context) + + if (import_method == 'web-download' and +- not utils.validate_import_uri(uri)): ++ not utils.validate_uri(uri)): + LOG.debug("URI for web-download does not pass filtering: %s", uri) + msg = (_("URI for web-download does not pass filtering: %s") % uri) + raise webob.exc.HTTPBadRequest(explanation=msg) +@@ -1160,6 +1160,17 @@ class ImagesController(object): + if validation_data is not None: + self._validate_hashing_data(validation_data) + ++ # NOTE(abhishekk): Reject restricted HTTP(S) hosts before ++ # spawning the async location_import task (same filter as ++ # web-download). ++ scheme = urlparse.urlparse(url).scheme ++ if scheme in ('http', 'https') and not utils.validate_uri(url): ++ LOG.debug("URI for add location does not pass filtering: %s", ++ url) ++ msg = (_("URI for add location does not pass filtering: %s") ++ % url) ++ raise webob.exc.HTTPBadRequest(explanation=msg) ++ + if 'os_glance_import_task' in image.extra_properties: + # NOTE(pdeore): This will raise exception.Conflict if the + # lock is present and valid, or return if absent or invalid. +Index: glance/glance/common/scripts/utils.py +=================================================================== +--- glance.orig/glance/common/scripts/utils.py ++++ glance/glance/common/scripts/utils.py +@@ -137,7 +137,7 @@ def validate_location_uri(location): + 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): ++ if not common_utils.validate_uri(uri): + msg = (_("URI for legacy import task does not pass filtering: %s") % + uri) + raise exception.Invalid(msg) +@@ -147,7 +147,7 @@ def validate_legacy_import_from_uri(loca + class SafeRedirectHandler(urllib.request.HTTPRedirectHandler): + """HTTP redirect handler that validates redirect destinations.""" + def redirect_request(self, req, fp, code, msg, headers, newurl): +- if not common_utils.validate_import_uri(newurl): ++ if not common_utils.validate_uri(newurl): + msg = (_("Redirect to disallowed URL: %s") % newurl) + raise exception.InvalidRedirect(msg) + return super().redirect_request(req, fp, code, msg, headers, newurl) +@@ -194,7 +194,7 @@ class _PinnedHTTPSConnection(http.client + def _pinned_ip_for_request(req): + """Return a pinned destination IP for an external HTTP(S) URI.""" + try: +- return common_utils.get_validated_import_address(req.full_url) ++ return common_utils.get_validated_address(req.full_url) + except ValueError as exc: + msg = (_("URI does not pass filtering: %s") % req.full_url) + LOG.debug("%s (%s)", msg, exc) +Index: glance/glance/common/store_utils.py +=================================================================== +--- glance.orig/glance/common/store_utils.py ++++ glance/glance/common/store_utils.py +@@ -20,6 +20,7 @@ from oslo_config import cfg + from oslo_log import log as logging + from oslo_utils import encodeutils + ++from glance.common import utils as common_utils + import glance.db as db_api + from glance.i18n import _LE, _LW + from glance import scrubber +@@ -139,6 +140,10 @@ def validate_external_location(uri): + see LP bug #942118, 1400966, 'swift+config://' is also + absent for security reasons, see LP bug #1334196. + ++ HTTP(S) locations are additionally checked with the same ++ ``import_filtering_opts`` host/port rules used for web-download ++ (see LP bug #2161330). ++ + :param uri: The URI of external image location. + :returns: Whether given URI of external image location are OK. + """ +@@ -152,8 +157,17 @@ def validate_external_location(uri): + if CONF.enabled_backends: + known_schemes = store_api.get_known_schemes_for_multi_store() + +- return (scheme in known_schemes and +- scheme not in RESTRICTED_URI_SCHEMAS) ++ if (scheme not in known_schemes or ++ scheme in RESTRICTED_URI_SCHEMAS): ++ return False ++ ++ # NOTE(abhishekk): Apply import host filtering to HTTP(S) locations so ++ # location add cannot SSRF glance-api into loopback/link-local targets ++ # (or other hosts blocked by import_filtering_opts). ++ if scheme in ('http', 'https'): ++ return common_utils.validate_uri(uri) ++ ++ return True + + + def _get_store_id_from_uri(uri): +Index: glance/glance/common/utils.py +=================================================================== +--- glance.orig/glance/common/utils.py ++++ glance/glance/common/utils.py +@@ -186,29 +186,32 @@ def normalize_hostname(host): + return host + + +-def default_import_port(scheme): +- """Return the default port for an import URI scheme.""" ++def default_port(scheme): ++ """Return the default port for an external HTTP(S) URI scheme.""" + return 443 if scheme == 'https' else 80 + + +-def resolve_pinned_import_address(hostname, port): +- """Resolve hostname and return the first import-allowed IP address. ++def resolve_pinned_address(hostname, port): ++ """Resolve hostname and return the first allowed IP address. + + Blocks loopback and link-local addresses (including cloud metadata + endpoints such as 169.254.169.254) unless the host is listed in + allowed_hosts. Private RFC1918 ranges are not blocked so private +- clouds can import from internal hosts. IPv4-mapped IPv6 addresses ++ clouds can fetch from internal hosts. IPv4-mapped IPv6 addresses + are checked against the embedded IPv4 address. Matching + disallowed_hosts IP entries also reject resolved addresses. + +- :param hostname: hostname or IP from the import URI +- :param port: destination port from the import URI ++ Used for web-download import and other external HTTP(S) fetches that ++ apply ``import_filtering_opts`` (for example HTTP image locations). ++ ++ :param hostname: hostname or IP from the external URI ++ :param port: destination port from the external URI + :returns: IP address string to connect to +- :raises ValueError: if the host cannot be used for import ++ :raises ValueError: if the host cannot be used for an external fetch + """ + normalized_host = normalize_hostname(hostname) + if not normalized_host: +- raise ValueError('invalid import host: %s' % hostname) ++ raise ValueError('invalid external host: %s' % hostname) + + bl_hosts = list(CONF.import_filtering_opts.disallowed_hosts) + wl_hosts = CONF.import_filtering_opts.allowed_hosts +@@ -233,7 +236,7 @@ def resolve_pinned_import_address(hostna + if addr not in addresses: + addresses.append(addr) + except (socket.gaierror, ValueError) as exc: +- raise ValueError('failed to resolve import host %s: %s' % ++ raise ValueError('failed to resolve external host %s: %s' % + (hostname, exc)) + + for addr in addresses: +@@ -262,17 +265,18 @@ def resolve_pinned_import_address(hostna + if not blocked: + return str(addr) + +- raise ValueError('no allowed addresses for import host: %s' % hostname) ++ raise ValueError('no allowed addresses for external host: %s' % hostname) + + +-def get_validated_import_address(uri): +- """Validate an import URI and return a pinned destination IP. ++def get_validated_address(uri): ++ """Validate an external URI and return a pinned destination IP. + +- Applies scheme/host/port filtering, resolves DNS, and returns the first +- allowed address. Raises ValueError if the URI must not be fetched. ++ Applies ``import_filtering_opts`` scheme/host/port filtering, resolves ++ DNS, and returns the first allowed address. Raises ValueError if the ++ URI must not be fetched. + """ + if not uri: +- raise ValueError('empty import URI') ++ raise ValueError('empty external URI') + + parsed_uri = urllib.parse.urlparse(uri) + scheme = parsed_uri.scheme +@@ -302,26 +306,26 @@ def get_validated_import_address(uri): + + if not scheme or ((wl_schemes and scheme not in wl_schemes) or + parsed_uri.scheme in bl_schemes): +- raise ValueError('scheme not allowed for import URI: %s' % uri) ++ raise ValueError('scheme not allowed for external URI: %s' % uri) + + normalized_host = normalize_hostname(host) + if not normalized_host or ( + (wl_hosts and normalized_host not in wl_hosts) or + normalized_host in bl_hosts): +- raise ValueError('host not allowed for import URI: %s' % uri) ++ raise ValueError('host not allowed for external URI: %s' % uri) + + if port and ((wl_ports and port not in wl_ports) or + port in bl_ports): +- raise ValueError('port not allowed for import URI: %s' % uri) ++ raise ValueError('port not allowed for external URI: %s' % uri) + +- resolve_port = port or default_import_port(scheme) +- return resolve_pinned_import_address(normalized_host, resolve_port) ++ resolve_port = port or default_port(scheme) ++ return resolve_pinned_address(normalized_host, resolve_port) + + +-def validate_import_uri(uri): +- """Return True if the URI passes image-import filtering.""" ++def validate_uri(uri): ++ """Return True if the URI passes ``import_filtering_opts`` filtering.""" + try: +- get_validated_import_address(uri) ++ get_validated_address(uri) + return True + except ValueError: + return False +Index: glance/glance/location.py +=================================================================== +--- glance.orig/glance/location.py ++++ glance/glance/location.py +@@ -130,6 +130,12 @@ def _check_location_uri(context, store_a + :param backend: A backend name for the store + """ + ++ # NOTE(abhishekk): Validate before contacting the backend so restricted ++ # HTTP(S) URIs never trigger an outbound request (SSRF). ++ if not store_utils.validate_external_location(uri): ++ reason = _('Invalid location') ++ raise exception.BadStoreUri(message=reason) ++ + try: + # NOTE(zhiyan): Some stores return zero when it catch exception + if CONF.enabled_backends: +@@ -139,8 +145,7 @@ def _check_location_uri(context, store_a + size_from_backend = store_api.get_size_from_backend( + uri, context=context) + +- is_ok = (store_utils.validate_external_location(uri) and +- size_from_backend > 0) ++ is_ok = size_from_backend > 0 + except (store.UnknownScheme, store.NotFound, store.BadStoreUri): + is_ok = False + if not is_ok: +Index: glance/glance/tests/functional/v2/test_images.py +=================================================================== +--- glance.orig/glance/tests/functional/v2/test_images.py ++++ glance/glance/tests/functional/v2/test_images.py +@@ -74,6 +74,9 @@ class TestImages(functional.FunctionalTe + self.cleanup() + self.include_scrubber = False + self.api_server.deployment_flavor = 'noauth' ++ # Location fixtures use 127.0.0.1 with ephemeral ports. Whitelist ++ # those hosts so SSRF filtering does not break fixture URLs. ++ self.allowed_hosts = ['127.0.0.1', 'localhost'] + for i in range(3): + ret = test_utils.start_http_server("foo_image_id%d" % i, + "foo_image%d" % i) +@@ -517,6 +520,8 @@ class TestImages(functional.FunctionalTe + + def test_web_download_ip_normalization(self): + """Test that encoded IP addresses are normalized and blocked.""" ++ # Clear fixture whitelist so loopback encodings are blocked. ++ self.allowed_hosts = [] + self.config(allowed_ports=[80], group='import_filtering_opts') + self.config(disallowed_hosts=['127.0.0.1'], + group='import_filtering_opts') +@@ -3896,6 +3901,76 @@ class TestImages(functional.FunctionalTe + response = requests.patch(path, headers=headers, data=data) + self.assertEqual(http.BAD_REQUEST, response.status_code, response.text) + ++ self.stop_servers() ++ ++ def test_add_location_blocks_restricted_http_hosts(self): ++ """HTTP locations targeting loopback/link-local are rejected early.""" ++ # Drop fixture whitelist so restricted addresses are blocked. ++ self.allowed_hosts = [] ++ self.start_servers(**self.__dict__.copy()) ++ ++ for url in ('http://127.0.0.1:80/secret', ++ 'http://169.254.169.254/latest/meta-data/'): ++ path = self._url('/v2/images') ++ headers = self._headers({'content-type': 'application/json'}) ++ data = jsonutils.dumps({'name': 'ssrf-location', ++ 'disk_format': 'aki', ++ 'container_format': 'aki'}) ++ response = requests.post(path, headers=headers, data=data) ++ self.assertEqual(http.CREATED, response.status_code) ++ image_id = jsonutils.loads(response.text)['id'] ++ ++ path = self._url('/v2/images/%s/locations' % image_id) ++ headers = self._headers() ++ response = requests.post(path, headers=headers, ++ json={'url': url}) ++ self.assertEqual(http.BAD_REQUEST, response.status_code, ++ response.text) ++ path = self._url('/v2/images/%s' % image_id) ++ image = requests.get(path, headers=headers).json() ++ self.assertEqual('queued', image['status']) ++ self.assertIsNone(image.get('checksum')) ++ self.assertIsNone(image.get('size')) ++ response = requests.delete(path, headers=headers) ++ self.assertEqual(http.NO_CONTENT, response.status_code) ++ ++ self.stop_servers() ++ ++ def test_update_locations_blocks_restricted_http_hosts(self): ++ """Old PATCH locations API also blocks restricted HTTP hosts.""" ++ self.allowed_hosts = [] ++ self.api_server.show_multiple_locations = True ++ self.start_servers(**self.__dict__.copy()) ++ ++ path = self._url('/v2/images') ++ headers = self._headers({'content-type': 'application/json'}) ++ data = jsonutils.dumps({'name': 'ssrf-patch-location', ++ 'disk_format': 'aki', ++ 'container_format': 'aki'}) ++ response = requests.post(path, headers=headers, data=data) ++ self.assertEqual(http.CREATED, response.status_code) ++ image_id = jsonutils.loads(response.text)['id'] ++ ++ path = self._url('/v2/images/%s' % image_id) ++ media_type = 'application/openstack-images-v2.1-json-patch' ++ headers = self._headers({'content-type': media_type}) ++ data = jsonutils.dumps([{ ++ 'op': 'replace', ++ 'path': '/locations', ++ 'value': [{ ++ 'url': 'http://169.254.169.254/latest/meta-data/', ++ 'metadata': {} ++ }] ++ }]) ++ response = requests.patch(path, headers=headers, data=data) ++ self.assertEqual(http.BAD_REQUEST, response.status_code, response.text) ++ ++ path = self._url('/v2/images/%s' % image_id) ++ response = requests.delete(path, headers=self._headers()) ++ self.assertEqual(http.NO_CONTENT, response.status_code) ++ ++ self.stop_servers() ++ + def test_add_location_with_do_secure_hash_true_negative(self): + self.start_servers(**self.__dict__.copy()) + +@@ -4531,6 +4606,7 @@ class TestImageLocationSelectionStrategy + self.cleanup() + self.include_scrubber = False + self.api_server.deployment_flavor = 'noauth' ++ self.allowed_hosts = ['127.0.0.1', 'localhost'] + for i in range(3): + ret = test_utils.start_http_server("foo_image_id%d" % i, + "foo_image%d" % i) +@@ -5073,6 +5149,7 @@ class TestImagesMultipleBackend(function + self.cleanup() + self.include_scrubber = False + self.api_server_multiple_backend.deployment_flavor = 'noauth' ++ self.allowed_hosts = ['127.0.0.1', 'localhost'] + for i in range(3): + ret = test_utils.start_http_server("foo_image_id%d" % i, + "foo_image%d" % i) +@@ -7968,6 +8045,14 @@ class TestMultipleBackendsLocationApi(fu + setattr(self, 'http_server%d' % i, ret[1]) + setattr(self, 'http_port%d' % i, ret[2]) + ++ def start_server(self): ++ self.config(allowed_hosts=['127.0.0.1', 'localhost'], ++ group='import_filtering_opts') ++ self.config(allowed_ports=[], group='import_filtering_opts') ++ with mock.patch.object(policy, 'Enforcer') as mock_enf: ++ mock_enf.return_value = self.policy ++ super(TestMultipleBackendsLocationApi, self).start_server() ++ + def setup_stores(self): + pass + +Index: glance/glance/tests/unit/common/scripts/test_scripts_utils.py +=================================================================== +--- glance.orig/glance/tests/unit/common/scripts/test_scripts_utils.py ++++ glance/glance/tests/unit/common/scripts/test_scripts_utils.py +@@ -261,7 +261,7 @@ class TestSafeRedirectHandler(test_utils + def setUp(self): + super(TestSafeRedirectHandler, self).setUp() + +- @mock.patch('glance.common.utils.validate_import_uri') ++ @mock.patch('glance.common.utils.validate_uri') + def test_redirect_to_allowed_url(self, mock_validate): + """Test redirect to allowed URL is accepted.""" + mock_validate.return_value = True +@@ -286,7 +286,7 @@ class TestSafeRedirectHandler(test_utils + # Should return a request object (not None) + self.assertIsNotNone(result) + +- @mock.patch('glance.common.utils.validate_import_uri') ++ @mock.patch('glance.common.utils.validate_uri') + def test_redirect_to_disallowed_url(self, mock_validate): + """Test redirect to disallowed URL raises error.""" + mock_validate.return_value = False +Index: glance/glance/tests/unit/common/test_store_utils.py +=================================================================== +--- /dev/null ++++ glance/glance/tests/unit/common/test_store_utils.py +@@ -0,0 +1,38 @@ ++# Copyright 2026 Red Hat, Inc. ++# All Rights Reserved. ++# ++# 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 glance.common import store_utils ++from glance.tests.unit import base as unit_base ++ ++ ++class TestValidateExternalLocation(unit_base.StoreClearingUnitTest): ++ ++ def test_http_restricted_hosts_rejected(self): ++ self.assertFalse( ++ store_utils.validate_external_location('http://127.0.0.1/x')) ++ self.assertFalse( ++ store_utils.validate_external_location( ++ 'http://169.254.169.254/latest/meta-data/')) ++ ++ def test_http_restricted_hosts_allowed_when_whitelisted(self): ++ self.config(allowed_hosts=['127.0.0.1'], ++ group='import_filtering_opts') ++ self.config(allowed_ports=[80], group='import_filtering_opts') ++ self.assertTrue( ++ store_utils.validate_external_location('http://127.0.0.1:80/x')) ++ ++ def test_file_scheme_still_rejected(self): ++ self.assertFalse( ++ store_utils.validate_external_location('file:///etc/passwd')) +Index: glance/glance/tests/unit/common/test_utils.py +=================================================================== +--- glance.orig/glance/tests/unit/common/test_utils.py ++++ glance/glance/tests/unit/common/test_utils.py +@@ -963,7 +963,7 @@ class EvaluateFilterOpTestCase(test_util + self.config(disallowed_hosts=['93.184.216.34'], + group='import_filtering_opts') + self.config(allowed_ports=[80], group='import_filtering_opts') +- self.assertFalse(utils.validate_import_uri("http://blocked.example/")) ++ self.assertFalse(utils.validate_uri("http://blocked.example/")) + + @mock.patch("glance.common.utils.socket.getaddrinfo") + def test_resolve_pinned_import_address(self, mock_getaddrinfo): +@@ -972,54 +972,54 @@ class EvaluateFilterOpTestCase(test_util + (socket.AF_INET, socket.SOCK_STREAM, 6, '', + ('93.184.216.34', 80)) + ] +- pinned = utils.resolve_pinned_import_address('example.com', 80) ++ pinned = utils.resolve_pinned_address('example.com', 80) + self.assertEqual(pinned, '93.184.216.34') + + + class ImportURITestCase(test_utils.BaseTestCase): + + @mock.patch("glance.common.utils.socket.getaddrinfo") +- def test_validate_import_uri(self, mock_getaddrinfo): +- # This avoid internet access in validate_import_uri() ++ def test_validate_uri(self, mock_getaddrinfo): ++ # This avoid internet access in validate_uri() + # (ie: DNS resolution of foo.com) + mock_getaddrinfo.return_value = [ + (socket.AF_INET, socket.SOCK_STREAM, 6, '', + ('93.184.216.34', 80)) + ] + +- self.assertTrue(utils.validate_import_uri("http://foo.com")) ++ self.assertTrue(utils.validate_uri("http://foo.com")) + + self.config(allowed_schemes=['http'], + group='import_filtering_opts') + self.config(allowed_hosts=['example.com'], + group='import_filtering_opts') +- self.assertTrue(utils.validate_import_uri("http://example.com")) ++ self.assertTrue(utils.validate_uri("http://example.com")) + + self.config(allowed_ports=['8080'], + group='import_filtering_opts') +- self.assertTrue(utils.validate_import_uri("http://example.com:8080")) ++ self.assertTrue(utils.validate_uri("http://example.com:8080")) + + # No need to mock eventlet.green.socket.getaddrinfo here, + # because this test checks that example and foo.com are blacklisted. + def test_invalid_import_uri(self): +- self.assertFalse(utils.validate_import_uri("")) ++ self.assertFalse(utils.validate_uri("")) + +- self.assertFalse(utils.validate_import_uri("fake_uri")) ++ self.assertFalse(utils.validate_uri("fake_uri")) + self.config(disallowed_schemes=['ftp'], + group='import_filtering_opts') +- self.assertFalse(utils.validate_import_uri("ftp://example.com")) ++ self.assertFalse(utils.validate_uri("ftp://example.com")) + + self.config(disallowed_hosts=['foo.com'], + group='import_filtering_opts') +- self.assertFalse(utils.validate_import_uri("ftp://foo.com")) ++ self.assertFalse(utils.validate_uri("ftp://foo.com")) + + self.config(disallowed_ports=['8484'], + group='import_filtering_opts') +- self.assertFalse(utils.validate_import_uri("http://localhost:8484")) ++ self.assertFalse(utils.validate_uri("http://localhost:8484")) + + @mock.patch("glance.common.utils.socket.getaddrinfo") + def test_ignored_filtering_options(self, mock_getaddrinfo): +- # This avoid internet access in validate_import_uri() ++ # This avoid internet access in validate_uri() + # (ie: DNS resolution of foo.com) + mock_getaddrinfo.return_value = [ + (socket.AF_INET, socket.SOCK_STREAM, 6, '', +@@ -1032,7 +1032,7 @@ class ImportURITestCase(test_utils.BaseT + group='import_filtering_opts') + self.config(disallowed_schemes=['ftp'], + group='import_filtering_opts') +- self.assertTrue(utils.validate_import_uri("ftp://foo.com")) ++ self.assertTrue(utils.validate_uri("ftp://foo.com")) + mock_run.assert_called_once() + with mock.patch.object(LOG, 'debug') as mock_run: + self.config(allowed_schemes=[], +@@ -1043,7 +1043,7 @@ class ImportURITestCase(test_utils.BaseT + group='import_filtering_opts') + self.config(disallowed_hosts=['foo.com'], + group='import_filtering_opts') +- self.assertTrue(utils.validate_import_uri("ftp://foo.com")) ++ self.assertTrue(utils.validate_uri("ftp://foo.com")) + mock_run.assert_called_once() + with mock.patch.object(LOG, 'debug') as mock_run: + self.config(allowed_hosts=[], +@@ -1054,35 +1054,36 @@ class ImportURITestCase(test_utils.BaseT + group='import_filtering_opts') + self.config(disallowed_ports=[8484], + group='import_filtering_opts') +- self.assertTrue(utils.validate_import_uri("ftp://foo.com:8484")) ++ self.assertTrue(utils.validate_uri("ftp://foo.com:8484")) + mock_run.assert_called_once() + +- def test_validate_import_uri_ip_rejection(self): ++ def test_validate_uri_ip_rejection(self): + """Test that encoded IP addresses are rejected (not normalized).""" + self.config(allowed_ports=[80], group='import_filtering_opts') + # Loopback and link-local addresses are blocked by default +- self.assertFalse(utils.validate_import_uri("http://127.0.0.1:80/")) +- self.assertFalse(utils.validate_import_uri("http://169.254.169.254/")) ++ self.assertFalse(utils.validate_uri("http://127.0.0.1:80/")) ++ self.assertFalse( ++ utils.validate_uri("http://169.254.169.254/")) + # Private RFC1918 addresses are allowed by default +- self.assertTrue(utils.validate_import_uri("http://10.0.0.1/")) +- self.assertTrue(utils.validate_import_uri("http://192.168.1.1/")) ++ self.assertTrue(utils.validate_uri("http://10.0.0.1/")) ++ self.assertTrue(utils.validate_uri("http://192.168.1.1/")) + + # Test that encoded IP (decimal) is rejected +- result = utils.validate_import_uri("http://2130706433:80/") ++ result = utils.validate_uri("http://2130706433:80/") + self.assertFalse(result) + + # Test that shorthand IP addresses are rejected +- self.assertFalse(utils.validate_import_uri("http://127.1:80/")) +- self.assertFalse(utils.validate_import_uri("http://10.1:80/")) +- self.assertFalse(utils.validate_import_uri("http://192.168.1:80/")) ++ self.assertFalse(utils.validate_uri("http://127.1:80/")) ++ self.assertFalse(utils.validate_uri("http://10.1:80/")) ++ self.assertFalse(utils.validate_uri("http://192.168.1:80/")) + + # Test with allowed host - loopback may be allowed via whitelist + self.config(disallowed_hosts=[], + group='import_filtering_opts') + self.config(allowed_hosts=['127.0.0.1'], + group='import_filtering_opts') +- self.assertTrue(utils.validate_import_uri("http://127.0.0.1:80/")) +- self.assertFalse(utils.validate_import_uri("http://2130706433:80/")) ++ self.assertTrue(utils.validate_uri("http://127.0.0.1:80/")) ++ self.assertFalse(utils.validate_uri("http://2130706433:80/")) + + @mock.patch('glance.common.utils.socket.getaddrinfo') + def test_normalize_hostname(self, mock_getaddrinfo): +@@ -1194,7 +1195,7 @@ class ImportURITestCase(test_utils.BaseT + mock_getaddrinfo.assert_called_once_with( + "invalid-hostname-12345.", 80) + +- def test_validate_import_uri_ipv6_validation(self): ++ def test_validate_uri_ipv6_validation(self): + """Test IPv6 addresses are properly validated against blacklist.""" + # Test that IPv6 localhost is blocked when in blacklist + self.config(disallowed_hosts=['::1'], +@@ -1204,7 +1205,7 @@ class ImportURITestCase(test_utils.BaseT + # IPv6 addresses in URLs are in brackets, but urlparse removes them + # So we test with the hostname directly + self.assertFalse( +- utils.validate_import_uri("http://[::1]:80/")) ++ utils.validate_uri("http://[::1]:80/")) + + # Test that IPv6 localhost is blocked by default + self.config(disallowed_hosts=[], +@@ -1213,14 +1214,15 @@ class ImportURITestCase(test_utils.BaseT + group='import_filtering_opts') + self.config(allowed_ports=[80], + group='import_filtering_opts') +- self.assertFalse(utils.validate_import_uri("http://[::1]:80/")) ++ self.assertFalse(utils.validate_uri("http://[::1]:80/")) + + # Test that IPv6 address not in blacklist is allowed when whitelisted + self.config(disallowed_hosts=[], + group='import_filtering_opts') + self.config(allowed_hosts=['2001:db8::1'], + group='import_filtering_opts') +- self.assertTrue(utils.validate_import_uri("http://[2001:db8::1]:80/")) ++ self.assertTrue( ++ utils.validate_uri("http://[2001:db8::1]:80/")) + + # Test that IPv6 localhost can be blocked separately from IPv4 + # This ensures IPv6 addresses are properly normalized and can be +@@ -1233,19 +1235,19 @@ class ImportURITestCase(test_utils.BaseT + group='import_filtering_opts') + # IPv6 localhost is blocked by default even when only IPv4 is listed + # in disallowed_hosts +- result = utils.validate_import_uri("http://[::1]:80/") ++ result = utils.validate_uri("http://[::1]:80/") + self.assertFalse(result) + + # Test that IPv6 can be blacklisted separately + self.config(disallowed_hosts=['127.0.0.1', '::1'], + group='import_filtering_opts') +- self.assertFalse(utils.validate_import_uri("http://[::1]:80/")) ++ self.assertFalse(utils.validate_uri("http://[::1]:80/")) + + @mock.patch("glance.common.utils.socket.getaddrinfo") +- def test_validate_import_uri_blocks_dns_rebinding(self, mock_getaddrinfo): ++ def test_validate_uri_blocks_dns_rebinding(self, mock_getaddrinfo): + """Hostnames resolving to restricted addresses are rejected.""" + mock_getaddrinfo.return_value = [ + (socket.AF_INET, socket.SOCK_STREAM, 6, '', + ('169.254.169.254', 80)) + ] +- self.assertFalse(utils.validate_import_uri("http://metadata.example/")) ++ self.assertFalse(utils.validate_uri("http://metadata.example/")) +Index: glance/glance/tests/unit/test_store_location.py +=================================================================== +--- glance.orig/glance/tests/unit/test_store_location.py ++++ glance/glance/tests/unit/test_store_location.py +@@ -82,3 +82,14 @@ class TestStoreLocation(base.StoreCleari + locations = glance.location.StoreLocations(image2, [loc1]) + self.assertRaises(exception.BadStoreUri, locations.insert, 0, loc2) + self.assertNotIn(loc2, locations) ++ ++ def test_add_http_location_blocks_restricted_hosts(self): ++ """HTTP locations to loopback/link-local are rejected before fetch.""" ++ image = TestStoreLocation.FakeImageProxy() ++ locations = glance.location.StoreLocations(image, []) ++ for url in ('http://127.0.0.1/secret', ++ 'http://169.254.169.254/latest/meta-data/'): ++ loc = {'url': url, 'metadata': {}} ++ self.assertRaises(exception.BadStoreUri, ++ locations.insert, 0, loc) ++ self.assertNotIn(loc, locations) +Index: glance/glance/tests/unit/v2/test_images_resource.py +=================================================================== +--- glance.orig/glance/tests/unit/v2/test_images_resource.py ++++ glance/glance/tests/unit/v2/test_images_resource.py +@@ -17,7 +17,7 @@ import datetime + import hashlib + import http.client as http + import os +-import requests ++import socket + from unittest import mock + import uuid + +@@ -27,6 +27,7 @@ from oslo_config import cfg + from oslo_serialization import jsonutils + from oslo_utils import fixture + from oslo_utils import timeutils ++import requests + import testtools + import webob + import webob.exc +@@ -218,6 +219,13 @@ class TestImagesController(base.Isolated + self.controller.gateway.store_utils = self.store_utils + self.controller._key_manager = fake_keymgr.fake_api() + store.create_stores() ++ # Avoid real DNS for HTTP location URI filtering in controller tests. ++ getaddrinfo = mock.patch( ++ 'glance.common.utils.socket.getaddrinfo', ++ return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ++ ('93.184.216.34', 0))]) ++ getaddrinfo.start() ++ self.addCleanup(getaddrinfo.stop) + + def _create_images(self): + self.images = [ +@@ -3964,6 +3972,24 @@ class TestImagesController(base.Isolated + user_id=request.context.user_id, + request_id=request.context.request_id) + ++ def test_add_location_blocks_restricted_http_hosts(self): ++ self.config(default_store='http', group='glance_store') ++ image_id = str(uuid.uuid4()) ++ self.images = [ ++ _db_fixture(image_id, owner=TENANT1, ++ name='1', ++ disk_format='raw', ++ container_format='bare', ++ status='queued'), ++ ] ++ self.db.image_create(None, self.images[0]) ++ request = unit_test_utils.get_fake_request() ++ for url in ('http://127.0.0.1:80/secret', ++ 'http://169.254.169.254/latest/meta-data/'): ++ self.assertRaises(webob.exc.HTTPBadRequest, ++ self.controller.add_location, ++ request, image_id, {'url': url}) ++ + @mock.patch.object(glance.notifier.TaskFactoryProxy, 'new_task') + def test_add_location_with_service_role(self, mock_task): + # Need to make sure 'http' store is not enabled +Index: glance/releasenotes/notes/bug-2161330-f8a1c3e5b7d90214.yaml +=================================================================== +--- /dev/null ++++ glance/releasenotes/notes/bug-2161330-f8a1c3e5b7d90214.yaml +@@ -0,0 +1,23 @@ ++--- ++security: ++ - | ++ Fixed a Server-Side Request Forgery (SSRF) vulnerability when adding ++ HTTP(S) image locations. Authenticated users could supply location URLs ++ targeting loopback or link-local addresses (for example cloud metadata ++ at ``169.254.169.254``). Glance would fetch those URLs from glance-api ++ during location validation and hashing. ++ ++ HTTP(S) locations are now checked with the same ++ ``import_filtering_opts`` host filtering used for web-download. Glance ++ validates the URI before contacting the backend so restricted destinations ++ never trigger an outbound request from the location-add path. ++ ++ This applies to ``POST /v2/images/{id}/locations`` and the legacy PATCH ++ locations API when HTTP store is enabled. ++ ++fixes: ++ - | ++ `Bug 2161330 `_: ++ Block restricted destination addresses when adding HTTP(S) image ++ locations to prevent SSRF against local services and cloud metadata ++ endpoints. 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 00:00:00.000000000 +0000 +++ glance-30.0.0/debian/patches/OSSN-0105_Apply_import_URI_filtering_to_legacy_import_tasks.patch 2026-08-31 20:49:17.000000000 +0000 @@ -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 +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 +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 `_: ++ 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 06:23:24.000000000 +0000 +++ glance-30.0.0/debian/patches/series 2026-08-31 20:49:17.000000000 +0000 @@ -2,3 +2,8 @@ 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 +CVE-2026-71196-71197-71198_1_Properly_limit_web-download_image_fetch_size.patch +CVE-2026-71196-71197-71198_2_Block_restricted_addresses_in_web-download_import_URIs.patch +CVE-2026-71196-71197-71198_3_Pin_import_downloads_to_validated_destination_addresses.patch +CVE-2026-71196-71197-71198_4_Block_restricted_hosts_when_adding_HTTP_image_locations.patch