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

Hi,

I would like to close this bug through p-u:
https://bugs.debian.org/1131274

[ Reason ]
This fixes CVE-2026-34881 / OSSA-2026-004.

[ Impact ]
Before the fix, someone can trick Glance to attempt a web-download
from a server that will do a redirect to a LAN IP address. For example,
something like this:

openstack image import --method web-download --uri \
        https://hacker-server.example.com/malicious-redirect \
        my-image

may redirect to an IP in the LAN of the OpenStack deployment. As a
result, the content of the document on the LAN will be saved as a
glance image, and can be retrived through:

openstack image save --file stolen-document.txt my-image

The proposed fix checks if the web-download URL has a redirect
and denies the operation if that is the case.

[ Tests ]
The proposed patch includes new tests, and this has been tested
in upstream functional CI too.

[ Risks ]
We've put this patch in production, and it worked well for us.
I haven't tested specifically this version of Glance, but I
believe it should be fine, thanks to unit tests.

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

I've attached debdiff for both Trixie and Bookworm. Please
allow me to upload to both p-u.

Note: I attempted a backport to Bullseye and failed, as the
code changed too much. Maybe someone from the LTS team wants
to take-over this work, but I'm giving up. As a mitigation,
it's possible to use a web proxy for Glance that wouldn't
give access to the LAN.

Cheers,

Thomas Goirand (zigo)
diff -Nru glance-25.1.0/debian/changelog glance-25.1.0/debian/changelog
--- glance-25.1.0/debian/changelog      2024-06-21 10:38:56.000000000 +0200
+++ glance-25.1.0/debian/changelog      2026-03-19 17:08:44.000000000 +0100
@@ -1,3 +1,13 @@
+glance (2:25.1.0-2+deb12u2) bookworm; urgency=medium
+
+  * Server-Side Request Forgery (SSRF) vulnerabilities in Glance image import.
+    By use of HTTP redirects, an authenticated user can bypass URL validation
+    checks and redirect to internal services. Add upstream patch:
+    - OSSA-2026-004_Fix_SSRF_vulnerabilities_in_image_import_API.patch.
+    (Closes: #1131274).
+
+ -- Thomas Goirand <[email protected]>  Thu, 19 Mar 2026 17:08:44 +0100
+
 glance (2:25.1.0-2+deb12u1) bookworm-security; urgency=high
 
   * CVE-2024-32498: Arbitrary file access through custom QCOW2 external data.
diff -Nru 
glance-25.1.0/debian/patches/OSSA-2026-004_Fix_SSRF_vulnerabilities_in_image_import_API.patch
 
glance-25.1.0/debian/patches/OSSA-2026-004_Fix_SSRF_vulnerabilities_in_image_import_API.patch
--- 
glance-25.1.0/debian/patches/OSSA-2026-004_Fix_SSRF_vulnerabilities_in_image_import_API.patch
       1970-01-01 01:00:00.000000000 +0100
+++ 
glance-25.1.0/debian/patches/OSSA-2026-004_Fix_SSRF_vulnerabilities_in_image_import_API.patch
       2026-03-19 17:08:44.000000000 +0100
@@ -0,0 +1,1267 @@
+Description: OSSA-2026-004: Fix SSRF vulnerabilities in image import API
+ Fixed Server-Side Request Forgery (SSRF) vulnerabilities in Glance's image
+ import functionality that could allow attackers to bypass URL validation
+ and access internal resources.
+ .
+ The fix includes:
+ - IP address validation using Python's ipaddress module to reject encoded
+   IP formats (decimal, hexadecimal, octal) that could bypass blacklist checks
+ - HTTP redirect validation for web-download, glance-download, and OVF
+   processing to prevent redirect-based SSRF attacks
+ - URI validation for OVF processing which previously had no protection
+ .
+ The implementation uses Python's built-in ipaddress module which inherently
+ rejects all non-standard IP encodings and only accepts standard formats,
+ providing robust protection against IP encoding bypass attacks.
+Author: Abhishek Kekane <[email protected]>
+Date: Tue, 20 Jan 2026 19:02:08 +0000
+Assisted-by: Used Cursor (Auto) for unit tests.
+Bug: https://launchpad.net/bugs/2138602
+Bug: https://launchpad.net/bugs/2138672
+Bug: https://launchpad.net/bugs/2138675
+Bug-Debian: https://bugs.debian.org/1131274
+Change-Id: Ib8d337dc68411d18c70d5712cc4f0986ef6205f4
+Signed-off-by: Abhishek Kekane <[email protected]>
+Origin: https://review.opendev.org/c/openstack/glance/+/981299
+Last-Update: 2026-03-19
+
+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
+@@ -24,6 +24,7 @@ from taskflow.patterns import linear_flo
+ from glance.async_.flows._internal_plugins import base_download
+ 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
+ 
+@@ -66,7 +67,9 @@ class _DownloadGlanceImage(base_download
+             token = self.context.auth_token
+             request = urllib.request.Request(image_download_url,
+                                              headers={'X-Auth-Token': token})
+-            data = urllib.request.urlopen(request)
++            opener = urllib.request.build_opener(
++                script_utils.SafeRedirectHandler)
++            data = opener.open(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
+@@ -817,7 +817,9 @@ class _ImportMetadata(task.Task):
+             token = self.context.auth_token
+             request = urllib.request.Request(image_download_metadata_url,
+                                              headers={'X-Auth-Token': token})
+-            with urllib.request.urlopen(request) as payload:
++            opener = urllib.request.build_opener(
++                script_utils.SafeRedirectHandler)
++            with opener.open(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
+@@ -18,6 +18,7 @@ import re
+ import shutil
+ import tarfile
+ import urllib
++import urllib.request
+ 
+ try:
+     from defusedxml import cElementTree as ET
+@@ -30,6 +31,9 @@ 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__)
+@@ -78,7 +82,13 @@ class _OVF_Process(task.Task):
+             uri = uri.split("file://")[-1]
+             return open(uri, "rb")
+ 
+-        return urllib.request.urlopen(uri)
++        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)
+ 
+     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
+@@ -19,14 +19,18 @@ __all__ = [
+     'set_base_image_properties',
+     'validate_location_uri',
+     'get_image_data_iter',
++    'SafeRedirectHandler',
+ ]
+ 
+ import urllib
++import urllib.error
++import urllib.request
+ 
+ from oslo_log import log as logging
+ from oslo_utils import timeutils
+ 
+ from glance.common import exception
++from glance.common import utils as common_utils
+ from glance.i18n import _, _LE
+ 
+ LOG = logging.getLogger(__name__)
+@@ -116,6 +120,15 @@ def validate_location_uri(location):
+         raise urllib.error.URLError(msg)
+ 
+ 
++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):
++            msg = (_("Redirect to disallowed URL: %s") % newurl)
++            raise exception.ImportTaskError(msg)
++        return super().redirect_request(req, fp, code, msg, headers, newurl)
++
++
+ def get_image_data_iter(uri):
+     """Returns iterable object either for local file or uri
+ 
+@@ -139,7 +152,8 @@ def get_image_data_iter(uri):
+         # into memory. Some images may be quite heavy.
+         return open(uri, "rb")
+ 
+-    return urllib.request.urlopen(uri)
++    opener = urllib.request.build_opener(SafeRedirectHandler)
++    return opener.open(uri)
+ 
+ 
+ class CallbackIterator(object):
+Index: glance/glance/common/utils.py
+===================================================================
+--- glance.orig/glance/common/utils.py
++++ glance/glance/common/utils.py
+@@ -21,6 +21,7 @@ System-level utilities and helper functi
+ """
+ 
+ import errno
++import ipaddress
+ 
+ try:
+     from eventlet import sleep
+@@ -129,6 +130,65 @@ CONF.import_group('import_filtering_opts
+                   'glance.async_.flows._internal_plugins')
+ 
+ 
++def normalize_hostname(host):
++    """Normalize IP address to standard format or return hostname.
++
++    Uses ipaddress module to validate and normalize IP addresses, rejecting
++    encoded formats. For hostnames, requires DNS resolution to ensure they
++    are valid and not encoded IP attempts.
++
++    :param host: hostname or IP address
++    :returns: normalized IP address, hostname unchanged, or None
++    """
++    if not host:
++        return host
++
++    # NOTE(abhishekk): Try to parse as IPv4. ipaddress module only accepts
++    # standard format like 127.0.0.1. It rejects encoded formats like
++    # decimal (2130706433), hex (0x7f000001), or octal (017700000001).
++    try:
++        return str(ipaddress.IPv4Address(host))
++    except ValueError:
++        pass
++
++    # NOTE(abhishekk): Try to parse as IPv6. ipaddress module only accepts
++    # standard IPv6 format and rejects encoded formats.
++    try:
++        return str(ipaddress.IPv6Address(host))
++    except ValueError:
++        pass
++
++    # NOTE(abhishekk): Not valid IP address, check as hostname. Reject pure
++    # numeric strings like "2130706433" (decimal encoded IP). ipaddress module
++    # rejected it, but OS might still resolve using inet_aton() if not 
blocked.
++    if host.isdigit():
++        return None
++
++    # NOTE(abhishekk): Reject all numeric strings with dots like "127.1" or
++    # "10.1". These are shorthand IP addresses. ipaddress module rejects them
++    # because they need 4 octets, but OS may still resolve them. We block to
++    # prevent SSRF bypass attacks.
++    if all(c.isdigit() or c == '.' for c in host):
++        return None
++
++    # NOTE(abhishekk): Add trailing dot to force DNS lookup instead of numeric
++    # parsing. This blocks encoded IP formats like 0x7f000001 or 127.0x0.0.1
++    # because they fail DNS lookup. Only real hostnames that resolve via DNS
++    # are allowed.
++    testhost = host
++    if not testhost.endswith('.'):
++        testhost += '.'
++
++    try:
++        socket.getaddrinfo(testhost, 80)
++    except socket.gaierror:
++        # NOTE(abhishekk): DNS resolution failed, reject the hostname
++        return None
++
++    # NOTE(abhishekk): Valid and resolvable hostname, return unchanged
++    return host
++
++
+ def validate_import_uri(uri):
+     """Validate requested uri for Image Import web-download.
+ 
+@@ -166,9 +226,14 @@ 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
+-    if not host or ((wl_hosts and host not in wl_hosts) or
+-                    host in bl_hosts):
++
++    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
++
+     if port and ((wl_ports and port not in wl_ports) or
+                  port in bl_ports):
+         return False
+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
+@@ -15,9 +15,11 @@
+ 
+ import hashlib
+ import http.client as http
++import http.server as http_server
+ import os
+ import subprocess
+ import tempfile
++import threading
+ import time
+ import urllib
+ import uuid
+@@ -377,6 +379,164 @@ class TestImages(functional.FunctionalTe
+ 
+         self.stop_servers()
+ 
++    def test_web_download_redirect_validation(self):
++        """Test that redirect destinations are validated."""
++        self.config(allowed_ports=[80], group='import_filtering_opts')
++        self.config(disallowed_hosts=['127.0.0.1'],
++                    group='import_filtering_opts')
++        self.start_servers(**self.__dict__.copy())
++
++        # Create an image
++        path = self._url('/v2/images')
++        headers = self._headers({'content-type': 'application/json'})
++        data = jsonutils.dumps({
++            'name': 'redirect-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 = jsonutils.loads(response.text)
++        image_id = image['id']
++
++        # Try to import with redirect to disallowed host
++        # The redirect destination should be validated and rejected
++        # Create a local redirect server
++        def _get_redirect_handler_class():
++            class RedirectHTTPRequestHandler(
++                    http_server.BaseHTTPRequestHandler):
++                def do_GET(self):
++                    self.send_response(http.FOUND)
++                    self.send_header('Location', 'http://127.0.0.1:80/')
++                    self.end_headers()
++                    return
++
++                def log_message(self, *args, **kwargs):
++                    return
++
++        server_address = ('127.0.0.1', 0)
++        handler_class = _get_redirect_handler_class()
++        redirect_httpd = http_server.HTTPServer(server_address, handler_class)
++        redirect_port = redirect_httpd.socket.getsockname()[1]
++        redirect_thread = 
threading.Thread(target=redirect_httpd.serve_forever)
++        redirect_thread.daemon = True
++        redirect_thread.start()
++
++        redirect_uri = 'http://127.0.0.1:%s/' % redirect_port
++        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': redirect_uri
++        }})
++
++        # Import request may be accepted (202) but should fail during
++        # processing when redirect destination is validated
++        response = requests.post(path, headers=headers, data=data)
++        self.assertEqual(http.ACCEPTED, response.status_code)
++
++        # Clean up redirect server
++        redirect_httpd.shutdown()
++        redirect_httpd.server_close()
++
++        # Wait for the task to process and verify it failed
++        # The redirect validation in get_image_data_iter should prevent
++        # access to disallowed host (127.0.0.1)
++        time.sleep(5)  # Give task time to process
++
++        # Verify the task failed due to redirect validation
++        path = self._url('/v2/images/%s/tasks' % image_id)
++        response = requests.get(path, headers=self._headers())
++        self.assertEqual(http.OK, response.status_code)
++        tasks = jsonutils.loads(response.text)['tasks']
++        tasks = sorted(tasks, key=lambda t: t['updated_at'])
++        self.assertGreater(len(tasks), 0)
++        task = tasks[-1]
++        self.assertEqual('failure', task['status'])
++
++        # Verify the image status is still queued (not active)
++        # since the import failed - this proves the SSRF was prevented
++        path = self._url('/v2/images/%s' % image_id)
++        response = requests.get(path, headers=self._headers())
++        self.assertEqual(http.OK, response.status_code)
++        image = jsonutils.loads(response.text)
++        self.assertEqual('queued', image['status'])
++        # Image should not have checksum or size since import failed
++        # If checksum/size exist, it means data was downloaded (SSRF 
succeeded)
++        self.assertIsNone(image.get('checksum'))
++        # Image should not have size since import failed
++        self.assertIsNone(image.get('size'))
++
++        # Clean up
++        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')
++        self.config(disallowed_hosts=['127.0.0.1'],
++                    group='import_filtering_opts')
++        self.start_servers(**self.__dict__.copy())
++
++        # Create an image
++        path = self._url('/v2/images')
++        headers = self._headers({'content-type': 'application/json'})
++        data = jsonutils.dumps({
++            'name': 'ip-encoding-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 = jsonutils.loads(response.text)
++        image_id = image['id']
++
++        # Test that encoded IP (2130706433 = 127.0.0.1) is blocked
++        # after normalization
++        encoded_ip_uri = 'http://2130706433:80/'
++        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': encoded_ip_uri
++        }})
++        # Should be rejected because encoded IP normalizes to 127.0.0.1
++        # which is in disallowed_hosts
++        # Validation happens at API level, so should return 400
++        response = requests.post(path, headers=headers, data=data)
++        # Should be rejected with 400 Bad Request
++        self.assertEqual(400, response.status_code)
++
++        # Test octal integer encoded IP (017700000001 = 127.0.0.1)
++        octal_int_uri = 'http://017700000001:80/'
++        data = jsonutils.dumps({'method': {
++            'name': 'web-download',
++            'uri': octal_int_uri
++        }})
++        response = requests.post(path, headers=headers, data=data)
++        self.assertEqual(400, response.status_code)
++
++        # Test octal dotted-decimal encoded IP (0177.0.0.01 = 127.0.0.1)
++        octal_dotted_uri = 'http://0177.0.0.01:80/'
++        data = jsonutils.dumps({'method': {
++            'name': 'web-download',
++            'uri': octal_dotted_uri
++        }})
++        response = requests.post(path, headers=headers, data=data)
++        self.assertEqual(400, response.status_code)
++
++        # Clean up
++        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_image_lifecycle(self):
+         # Image list should be empty
+         self.api_server.show_multiple_locations = True
+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,6 +25,7 @@ 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
+@@ -1176,6 +1177,11 @@ class TestImportMetadata(test_utils.Base
+             'extra_metadata': 'hello',
+             '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
+         task = import_flow._ImportMetadata(TASK_ID1, TASK_TYPE,
+                                            self.context, self.wrapper,
+                                            self.import_req)
+@@ -1184,6 +1190,7 @@ class TestImportMetadata(test_utils.Base
+             'https://other.cloud.foo/image/v2/images/%s' % (
+                 IMAGE_ID1),
+             headers={'X-Auth-Token': self.context.auth_token})
++        mock_request.build_opener.assert_called_once()
+         mock_gge.assert_called_once_with(self.context, 'RegionTwo', 'public')
+         action.set_image_attribute.assert_called_once_with(
+             disk_format='qcow2',
+@@ -1212,8 +1219,11 @@ class TestImportMetadata(test_utils.Base
+     @mock.patch('glance.async_.utils.get_glance_endpoint')
+     def test_execute_fail_remote_glance_unreachable(self, mock_gge, mock_r):
+         action = self.wrapper.__enter__.return_value
+-        mock_r.urlopen.side_effect = urllib.error.HTTPError(
++        mock_gge.return_value = 'https://other.cloud.foo/image'
++        mock_opener = mock.MagicMock()
++        mock_opener.open.side_effect = urllib.error.HTTPError(
+             '/file', 400, 'Test Fail', {}, None)
++        mock_r.build_opener.return_value = mock_opener
+         task = import_flow._ImportMetadata(TASK_ID1, TASK_TYPE,
+                                            self.context, self.wrapper,
+                                            self.import_req)
+@@ -1231,6 +1241,11 @@ class TestImportMetadata(test_utils.Base
+         mock_json.loads.return_value = {
+             'status': 'queued',
+         }
++        mock_opener = mock.MagicMock()
++        mock_payload = mock.MagicMock()
++        mock_payload.read.return_value = b'{"status": "queued"}'
++        mock_opener.open.return_value.__enter__.return_value = mock_payload
++        mock_request.build_opener.return_value = mock_opener
+         task = import_flow._ImportMetadata(TASK_ID1, TASK_TYPE,
+                                            self.context, self.wrapper,
+                                            self.import_req)
+@@ -1254,6 +1269,11 @@ class TestImportMetadata(test_utils.Base
+             'os_hash': 'hash',
+             'extra_metadata': 'hello',
+         }
++        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
+         task = import_flow._ImportMetadata(TASK_ID1, TASK_TYPE,
+                                            self.context, self.wrapper,
+                                            self.import_req)
+@@ -1262,6 +1282,7 @@ class TestImportMetadata(test_utils.Base
+             'https://other.cloud.foo/image/v2/images/%s' % (
+                 IMAGE_ID1),
+             headers={'X-Auth-Token': self.context.auth_token})
++        mock_request.build_opener.assert_called_once()
+         mock_gge.assert_called_once_with(self.context, 'RegionTwo', 'public')
+         action.set_image_attribute.assert_called_once_with(
+             disk_format='qcow2',
+@@ -1271,6 +1292,71 @@ class TestImportMetadata(test_utils.Base
+             'os_hash': 'hash'
+         })
+ 
++    @mock.patch('urllib.request')
++    @mock.patch('glance.async_.utils.get_glance_endpoint')
++    def test_import_metadata_redirect_validation(self, mock_gge,
++                                                 mock_request):
++        """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(
++            "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")
++
++    @mock.patch('urllib.request')
++    @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."""
++        mock_gge.return_value = 'https://other.cloud.foo/image'
++        mock_json.loads.return_value = {
++            'status': 'active',
++            'disk_format': 'qcow2',
++            '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
++        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)
++        self.assertEqual(12345, result)
++
+     def test_revert_rollback_metadata_value(self):
+         action = self.wrapper.__enter__.return_value
+         task = import_flow._ImportMetadata(TASK_ID1, TASK_TYPE,
+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
+@@ -22,7 +22,8 @@ from oslo_utils.fixture import uuidsenti
+ 
+ from glance.async_.flows._internal_plugins import glance_download
+ from glance.async_.flows import api_image_import
+-import glance.common.exception
++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
+@@ -72,37 +73,49 @@ class TestGlanceDownloadTask(test_utils.
+         self.image_repo.get.return_value = mock.MagicMock(
+             extra_properties={'os_glance_import_task': self.task_id})
+ 
++    @mock.patch('glance.common.utils.socket.getaddrinfo')
+     @mock.patch.object(filesystem.Store, 'add')
+     @mock.patch('glance.async_.utils.get_glance_endpoint')
+-    def test_glance_download(self, mock_gge, mock_add):
++    def test_glance_download(self, mock_gge, mock_add, mock_getaddrinfo):
++        mock_getaddrinfo.return_value = [('', '', '', '', ('', 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')
+         with mock.patch('urllib.request') as mock_request:
++            mock_opener = mock.MagicMock()
++            mock_response = mock.MagicMock()
++            mock_opener.open.return_value = mock_response
++            mock_request.build_opener.return_value = mock_opener
+             mock_add.return_value = ["path", 12345]
+             self.assertEqual(glance_download_task.execute(12345), "path")
+             mock_add.assert_called_once_with(
+                 self.image_id,
+-                mock_request.urlopen.return_value, 0)
++                mock_response, 0)
+             mock_request.Request.assert_called_once_with(
+                 'https://other.cloud.foo/image/v2/images/%s/file' % (
+                     uuidsentinel.remote_image),
+                 headers={'X-Auth-Token': self.context.auth_token})
++            mock_request.build_opener.assert_called_once()
+         mock_gge.assert_called_once_with(self.context, 'RegionTwo', 'public')
+ 
++    @mock.patch('glance.common.utils.socket.getaddrinfo')
+     @mock.patch.object(filesystem.Store, 'add')
+     @mock.patch('glance.async_.utils.get_glance_endpoint')
+-    def test_glance_download_failed(self, mock_gge, mock_add):
++    def test_glance_download_failed(self, mock_gge, mock_add,
++                                    mock_getaddrinfo):
++        mock_getaddrinfo.return_value = [('', '', '', '', ('', 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')
+         with mock.patch('urllib.request') as mock_request:
+-            mock_request.urlopen.side_effect = urllib.error.HTTPError(
++            mock_opener = mock.MagicMock()
++            mock_opener.open.side_effect = urllib.error.HTTPError(
+                 '/file', 400, 'Test Fail', {}, None)
++            mock_request.build_opener.return_value = mock_opener
+             self.assertRaises(urllib.error.HTTPError,
+                               glance_download_task.execute,
+                               12345)
+@@ -127,21 +140,28 @@ class TestGlanceDownloadTask(test_utils.
+                           glance_download_task.execute, 12345)
+         mock_request.assert_not_called()
+ 
++    @mock.patch('glance.common.utils.socket.getaddrinfo')
+     @mock.patch.object(filesystem.Store, 'add')
+     @mock.patch('glance.async_.utils.get_glance_endpoint')
+-    def test_glance_download_size_mismatch(self, mock_gge, mock_add):
++    def test_glance_download_size_mismatch(self, mock_gge, mock_add,
++                                           mock_getaddrinfo):
++        mock_getaddrinfo.return_value = [('', '', '', '', ('', 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')
+         with mock.patch('urllib.request') as mock_request:
++            mock_opener = mock.MagicMock()
++            mock_response = mock.MagicMock()
++            mock_opener.open.return_value = mock_response
++            mock_request.build_opener.return_value = mock_opener
+             mock_add.return_value = ["path", 1]
+             self.assertRaises(glance.common.exception.ImportTaskError,
+                               glance_download_task.execute, 12345)
+             mock_add.assert_called_once_with(
+                 self.image_id,
+-                mock_request.urlopen.return_value, 0)
++                mock_response, 0)
+             mock_request.Request.assert_called_once_with(
+                 'https://other.cloud.foo/image/v2/images/%s/file' % (
+                     uuidsentinel.remote_image),
+@@ -165,3 +185,70 @@ class TestGlanceDownloadTask(test_utils.
+         mock_validate.assert_called_once_with(
+             'https://other.cloud.foo/image/v2/images/%s/file' % (
+                 uuidsentinel.remote_image))
++
++    @mock.patch('glance.common.utils.socket.getaddrinfo')
++    @mock.patch('urllib.request')
++    @mock.patch('glance.async_.utils.get_glance_endpoint')
++    def test_glance_download_redirect_validation(self, mock_gge,
++                                                 mock_request,
++                                                 mock_getaddrinfo):
++        """Test redirect destinations are validated during image download."""
++        mock_getaddrinfo.return_value = [('', '', '', '', ('', 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(
++            "Redirect to disallowed URL: http://127.0.0.1:5000/";)
++        mock_request.build_opener.return_value = mock_opener
++        self.assertRaises(exception.ImportTaskError,
++                          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.patch('glance.common.utils.socket.getaddrinfo')
++    @mock.patch.object(filesystem.Store, 'add')
++    @mock.patch('urllib.request')
++    @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."""
++        mock_getaddrinfo.return_value = [('', '', '', '', ('', 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_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)
++        self.assertEqual("path", result)
+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
+@@ -17,7 +17,6 @@ import io
+ import json
+ import os
+ from unittest import mock
+-import urllib
+ 
+ import glance_store
+ from oslo_concurrency import processutils as putils
+@@ -371,9 +370,15 @@ class TestImportTask(test_utils.BaseTest
+         self.img_repo.get.return_value = self.image
+         img_factory.new_image.side_effect = create_image
+ 
+-        with mock.patch.object(urllib.request, 'urlopen') as umock:
+-            content = b"TEST_IMAGE"
+-            umock.return_value = io.BytesIO(content)
++        # Mock get_image_data_iter to avoid actual network calls
++        # and to work with our SafeRedirectHandler changes
++        content = b"TEST_IMAGE"
++        mock_response = io.BytesIO(content)
++        mock_response.headers = {}
++
++        with mock.patch(
++                'glance.common.scripts.utils.get_image_data_iter') as umock:
++            umock.return_value = mock_response
+ 
+             with mock.patch.object(import_flow, "_get_import_flows") as imock:
+                 imock.return_value = (x for x in [])
+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,10 +18,12 @@ import shutil
+ import tarfile
+ import tempfile
+ from unittest import mock
++import urllib.error
+ 
+ from defusedxml.ElementTree import ParseError
+ 
+ from glance.async_.flows import ovf_process
++from glance.common import exception
+ import glance.tests.utils as test_utils
+ from oslo_config import cfg
+ 
+@@ -164,3 +166,56 @@ class TestOvfProcessTask(test_utils.Base
+         iextractor = ovf_process.OVAImageExtractor()
+         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
++        oprocess = ovf_process._OVF_Process('task_id', 'ovf_proc',
++                                            self.img_repo)
++        self.assertRaises(exception.ImportTaskError,
++                          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.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_response = mock.MagicMock()
++        mock_opener.open.return_value = mock_response
++        mock_request.build_opener.return_value = mock_opener
++        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.patch('urllib.request')
++    @mock.patch('glance.common.utils.validate_import_uri')
++    def test_get_ova_iter_objects_redirect_validation(self, mock_validate,
++                                                      mock_request):
++        """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(
++            "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,
++                          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()
+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
+@@ -15,6 +15,8 @@
+ 
+ from unittest import mock
+ import urllib
++import urllib.error
++import urllib.request
+ 
+ from glance.common import exception
+ from glance.common.scripts import utils as script_utils
+@@ -203,3 +205,110 @@ class TestCallbackIterator(test_utils.Ba
+         # call the callback with that.
+         callback.assert_has_calls([mock.call(2, 2),
+                                    mock.call(1, 3)])
++
++
++class TestSafeRedirectHandler(test_utils.BaseTestCase):
++    """Test SafeRedirectHandler for redirect validation."""
++
++    def setUp(self):
++        super(TestSafeRedirectHandler, self).setUp()
++
++    @mock.patch('glance.common.utils.validate_import_uri')
++    def test_redirect_to_allowed_url(self, mock_validate):
++        """Test redirect to allowed URL is accepted."""
++        mock_validate.return_value = True
++        handler = script_utils.SafeRedirectHandler()
++
++        req = mock.Mock()
++        req.full_url = 'http://example.com/redirect'
++        fp = mock.Mock()
++        headers = mock.Mock()
++
++        # Redirect to allowed URL
++        # redirect_request should call super().redirect_request
++        # which returns a request
++        with mock.patch.object(urllib.request.HTTPRedirectHandler,
++                               'redirect_request') as mock_super:
++            mock_super.return_value = mock.Mock()
++            result = handler.redirect_request(
++                req, fp, 302, 'Found', headers, 'http://allowed.com/target'
++            )
++
++        mock_validate.assert_called_once_with('http://allowed.com/target')
++        # Should return a request object (not None)
++        self.assertIsNotNone(result)
++
++    @mock.patch('glance.common.utils.validate_import_uri')
++    def test_redirect_to_disallowed_url(self, mock_validate):
++        """Test redirect to disallowed URL raises error."""
++        mock_validate.return_value = False
++        handler = script_utils.SafeRedirectHandler()
++
++        req = mock.Mock()
++        req.full_url = 'http://example.com/redirect'
++        fp = mock.Mock()
++        headers = mock.Mock()
++
++        # Redirect to disallowed URL should raise ImportTaskError
++        self.assertRaises(
++            exception.ImportTaskError,
++            handler.redirect_request,
++            req, fp, 302, 'Found', headers, 'http://127.0.0.1:5000/'
++        )
++
++        mock_validate.assert_called_once_with('http://127.0.0.1:5000/')
++
++
++class TestGetImageDataIter(test_utils.BaseTestCase):
++    """Test get_image_data_iter with redirect validation."""
++
++    def setUp(self):
++        super(TestGetImageDataIter, self).setUp()
++
++    @mock.patch('builtins.open', create=True)
++    def test_get_image_data_iter_file_uri(self, mock_open):
++        """Test file:// URI handling."""
++        mock_file = mock.Mock()
++        mock_open.return_value = mock_file
++
++        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.patch('urllib.request.build_opener')
++    def test_get_image_data_iter_http_uri(self, mock_build_opener):
++        """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";)
++
++        # Should use build_opener with SafeRedirectHandler
++        mock_build_opener.assert_called_once()
++        # Check that SafeRedirectHandler was passed as an argument
++        call_args = mock_build_opener.call_args
++        # build_opener can be called with *args or keyword args
++        # Check both positional and keyword arguments
++        found_handler = False
++        if call_args.args:
++            found_handler = any(
++                isinstance(arg, script_utils.SafeRedirectHandler)
++                for arg in call_args.args)
++        if not found_handler and call_args.kwargs:
++            found_handler = any(
++                isinstance(v, script_utils.SafeRedirectHandler)
++                for v in call_args.kwargs.values())
++        # Also check if it's passed as a handler class (not instance)
++        if not found_handler:
++            found_handler = (
++                script_utils.SafeRedirectHandler in call_args.args)
++
++        self.assertTrue(
++            found_handler,
++            "SafeRedirectHandler should be passed to build_opener")
++
++        mock_opener.open.assert_called_once_with("http://example.com/image";)
++        self.assertEqual(result, mock_response)
+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
+@@ -15,6 +15,8 @@
+ #    under the License.
+ 
+ import io
++import ipaddress
++import socket
+ import tempfile
+ from unittest import mock
+ 
+@@ -818,3 +820,179 @@ class ImportURITestCase(test_utils.BaseT
+                         group='import_filtering_opts')
+             self.assertTrue(utils.validate_import_uri("ftp://foo.com:8484";))
+             mock_run.assert_called_once()
++
++    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.assertFalse(utils.validate_import_uri("http://127.0.0.1:80/";))
++
++        # Test that encoded IP (decimal) is rejected
++        result = utils.validate_import_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/";))
++
++        # Test with allowed host - encoded IP should still be rejected
++        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/";))
++
++    @mock.patch('glance.common.utils.socket.getaddrinfo')
++    def test_normalize_hostname(self, mock_getaddrinfo):
++        """Test the normalize_hostname function."""
++        # Test standard IPv4 - should return normalized
++        result = utils.normalize_hostname("127.0.0.1")
++        self.assertEqual(result, "127.0.0.1")
++        mock_getaddrinfo.assert_not_called()
++
++        # Test standard IPv4 with different format - should normalize
++        result = utils.normalize_hostname("192.168.1.1")
++        self.assertEqual(result, "192.168.1.1")
++        mock_getaddrinfo.assert_not_called()
++
++        # Test encoded IP (decimal) - should be rejected (fails isdigit check)
++        result = utils.normalize_hostname("2130706433")
++        self.assertIsNone(result)
++        mock_getaddrinfo.assert_not_called()
++
++        # Test hex encoded IP - should be rejected (DNS resolution fails)
++        mock_getaddrinfo.reset_mock()
++        mock_getaddrinfo.side_effect = socket.gaierror(
++            "Name or service not known")
++        result = utils.normalize_hostname("0x7f000001")
++        self.assertIsNone(result)
++        mock_getaddrinfo.assert_called_once_with("0x7f000001.", 80)
++
++        # Test octal integer encoded IP - should be rejected
++        # (fails isdigit check)
++        mock_getaddrinfo.reset_mock()
++        result = utils.normalize_hostname("017700000001")
++        self.assertIsNone(result)
++        mock_getaddrinfo.assert_not_called()
++
++        # Test octal dotted-decimal encoded IP - should be rejected
++        # (all digits/dots)
++        mock_getaddrinfo.reset_mock()
++        result = utils.normalize_hostname("0177.0.0.01")
++        self.assertIsNone(result)
++        mock_getaddrinfo.assert_not_called()
++
++        # Test mixed octal/decimal dotted-decimal - should be rejected
++        # (all digits/dots)
++        mock_getaddrinfo.reset_mock()
++        result = utils.normalize_hostname("0177.0.0.1")
++        self.assertIsNone(result)
++        mock_getaddrinfo.assert_not_called()
++
++        # Test IPv6 address - should normalize to standard format
++        mock_getaddrinfo.reset_mock()
++        result = utils.normalize_hostname("::1")
++        self.assertEqual(result, "::1")
++        mock_getaddrinfo.assert_not_called()
++
++        result = utils.normalize_hostname("2001:db8::1")
++        self.assertEqual(result, "2001:db8::1")
++        mock_getaddrinfo.assert_not_called()
++
++        # Test IPv6-mapped IPv4 - should normalize
++        result = utils.normalize_hostname("::ffff:127.0.0.1")
++        ipv6 = ipaddress.IPv6Address(result)
++        expected = ipaddress.IPv6Address("::ffff:127.0.0.1")
++        self.assertEqual(ipv6, expected)
++        mock_getaddrinfo.assert_not_called()
++
++        # Test shorthand IP addresses - should be rejected
++        mock_getaddrinfo.reset_mock()
++        result = utils.normalize_hostname("127.1")
++        self.assertIsNone(result)
++        mock_getaddrinfo.assert_not_called()
++
++        result = utils.normalize_hostname("10.1")
++        self.assertIsNone(result)
++
++        result = utils.normalize_hostname("192.168.1")
++        self.assertIsNone(result)
++
++        # Test valid hostname - should return unchanged
++        mock_getaddrinfo.reset_mock()
++        mock_getaddrinfo.side_effect = None
++        mock_getaddrinfo.return_value = [('', '', '', '', ('', 80))]
++        result = utils.normalize_hostname("example.com")
++        self.assertEqual(result, "example.com")
++        mock_getaddrinfo.assert_called_once_with("example.com.", 80)
++
++        # Test valid domain starting with digit (3m.com)
++        mock_getaddrinfo.reset_mock()
++        mock_getaddrinfo.side_effect = None
++        mock_getaddrinfo.return_value = [('', '', '', '', ('', 80))]
++        result = utils.normalize_hostname("3m.com")
++        self.assertEqual(result, "3m.com")
++        mock_getaddrinfo.assert_called_once_with("3m.com.", 80)
++
++        # Test valid domain starting with 0x (0xdeadbeef.com)
++        mock_getaddrinfo.reset_mock()
++        mock_getaddrinfo.side_effect = None
++        mock_getaddrinfo.return_value = [('', '', '', '', ('', 80))]
++        result = utils.normalize_hostname("0xdeadbeef.com")
++        self.assertEqual(result, "0xdeadbeef.com")
++        mock_getaddrinfo.assert_called_once_with(
++            "0xdeadbeef.com.", 80)
++
++        # Test invalid/unresolvable hostname - should be rejected
++        mock_getaddrinfo.reset_mock()
++        mock_getaddrinfo.side_effect = socket.gaierror(
++            "Name or service not known")
++        result = utils.normalize_hostname("invalid-hostname-12345")
++        self.assertIsNone(result)
++        mock_getaddrinfo.assert_called_once_with(
++            "invalid-hostname-12345.", 80)
++
++    def test_validate_import_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'],
++                    group='import_filtering_opts')
++        self.config(allowed_ports=[80],
++                    group='import_filtering_opts')
++        # 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/";))
++
++        # Test that IPv6 address not in blacklist is allowed
++        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/";))
++
++        # Test that IPv6 localhost can be blocked separately from IPv4
++        # This ensures IPv6 addresses are properly normalized and can be
++        # blacklisted
++        self.config(disallowed_hosts=['127.0.0.1'],
++                    group='import_filtering_opts')
++        self.config(allowed_hosts=[],  # Explicitly clear whitelist
++                    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
++        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)
++
++        # 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/";))
+Index: glance/releasenotes/notes/bug-2138602-5720ad2e501b9e57.yaml
+===================================================================
+--- /dev/null
++++ glance/releasenotes/notes/bug-2138602-5720ad2e501b9e57.yaml
+@@ -0,0 +1,94 @@
++---
++security:
++  - |
++    Fixed multiple Server-Side Request Forgery (SSRF) vulnerabilities in
++    Glance's image import functionality. These vulnerabilities could allow
++    attackers to bypass URL validation and access internal resources.
++
++    **web-download Import Method SSRF:**
++
++    The web-download import method had two SSRF vulnerabilities:
++
++    *HTTP Redirect Bypass:*
++    The web-download import method did not validate redirect destinations when
++    following HTTP redirects. An attacker could provide an initial URL that
++    passed validation, but redirect to an internal or disallowed resource that
++    would bypass the security checks. This has been fixed by implementing
++    ``SafeRedirectHandler`` that validates redirect destinations before
++    following them using the same ``validate_import_uri()`` checks as the
++    initial URL.
++
++    *IP Address Encoding Bypass:*
++    The web-download import method's URL validation could be bypassed by
++    encoding IP addresses in alternative formats (decimal integer,
++    hexadecimal, octal). For example, ``127.0.0.1`` could be encoded as
++    ``2130706433`` (decimal) or ``0x7f000001`` (hexadecimal) to bypass
++    blacklist checks. This has been fixed by implementing
++    ``normalize_hostname()`` function that uses Python's ``ipaddress`` module
++    to validate IP addresses. The ``ipaddress`` module only accepts standard
++    dotted-decimal notation for IPv4 and standard format for IPv6, 
automatically
++    rejecting all encoded formats (decimal, hexadecimal, octal). Any attempt 
to
++    use encoded IP formats is rejected, preventing SSRF bypass attacks.
++
++    **glance-download Import Method SSRF:**
++
++    The glance-download import method had redirect validation bypass
++    vulnerabilities in two steps of the import flow:
++
++    *Image Data Download:*
++    When downloading image data from a remote Glance endpoint, redirects were
++    not validated, allowing attackers to redirect to internal services.
++
++    *Metadata Fetch:*
++    When fetching image metadata from a remote Glance endpoint, redirects were
++    not validated, allowing attackers to redirect to internal services.
++
++    Both steps have been fixed by using ``SafeRedirectHandler`` to validate
++    redirect destinations before following them.
++
++    **OVF Processing SSRF:**
++
++    The OVF processing functionality had a critical SSRF vulnerability with
++    zero protection - no URI validation, no redirect validation, and no IP
++    normalization. The code directly called ``urllib.request.urlopen(uri)``
++    without any validation checks. This has been fixed by adding URI
++    validation using ``validate_import_uri()`` and redirect validation using
++    ``SafeRedirectHandler``.
++
++    **Affected Components:**
++    - ``glance.common.scripts.utils.get_image_data_iter()``
++    - ``glance.common.utils.validate_import_uri()``
++    - 
``glance.async_.flows._internal_plugins.glance_download._DownloadGlanceImage.execute()``
++    - ``glance.async_.flows.api_image_import._ImportMetadata.execute()``
++    - ``glance.async_.flows.ovf_process._OVF_Process._get_ova_iter_objects()``
++
++    **Impact:**
++    - Severity: High (web-download, glance-download), Critical (OVF 
processing)
++    - Affected Versions: All versions prior to this fix
++    - Workaround: Administrators can temporarily disable affected import
++      methods by removing them from the ``enabled_import_methods``
++      configuration option
++
++    Bugs `#2138602 <https://bugs.launchpad.net/glance/+bug/2138602>`_,
++    `#2138672 <https://bugs.launchpad.net/glance/+bug/2138672>`_,
++    `#2138675 <https://bugs.launchpad.net/glance/+bug/2138675>`_
++
++fixes:
++  - |
++    Bug 2138602_: Fixed SSRF vulnerability in web-download import method via
++    HTTP redirect bypass and IP address encoding bypass. Added redirect
++    validation using SafeRedirectHandler and IP address validation using
++    Python's ``ipaddress`` module to reject encoded IP formats and prevent
++    bypass attacks.
++
++    Bug 2138672_: Fixed SSRF vulnerability in glance-download import method
++    via HTTP redirect bypass. Added redirect validation for both image data
++    download and metadata fetch operations.
++
++    Bug 2138675_: Fixed SSRF vulnerability in OVF processing functionality
++    which lacked URI validation. Added URI validation and redirect validation
++    to prevent SSRF attacks when processing OVA files.
++
++    .. _2138602: https://bugs.launchpad.net/bugs/2138602
++    .. _2138672: https://bugs.launchpad.net/bugs/2138672
++    .. _2138675: https://bugs.launchpad.net/bugs/2138675
diff -Nru glance-25.1.0/debian/patches/series 
glance-25.1.0/debian/patches/series
--- glance-25.1.0/debian/patches/series 2024-06-21 10:38:56.000000000 +0200
+++ glance-25.1.0/debian/patches/series 2026-03-19 17:08:44.000000000 +0100
@@ -9,3 +9,4 @@
 CVE-2024-32498_3_5_glance-stable-2023.1.patch
 CVE-2024-32498_3_6_glance-stable-2023.1.patch
 CVE-2024-32498_3_7_glance-stable-2023.1.patch
+OSSA-2026-004_Fix_SSRF_vulnerabilities_in_image_import_API.patch
diff -Nru glance-30.0.0/debian/changelog glance-30.0.0/debian/changelog
--- glance-30.0.0/debian/changelog      2025-07-12 10:29:31.000000000 +0200
+++ glance-30.0.0/debian/changelog      2026-04-05 16:42:49.000000000 +0200
@@ -1,3 +1,14 @@
+glance (2:30.0.0-3+deb13u1) trixie; urgency=medium
+
+  * Server-Side Request Forgery (SSRF) vulnerabilities in Glance image import.
+    By use of HTTP redirects, an authenticated user can bypass URL validation
+    checks and redirect to internal services. Add upstream patch:
+    - OSSA-2026-004_Fix_SSRF_vulnerabilities_in_image_import_API.patch.
+    (Closes: #1131274).
+  * Refreshed debian/patches/sql_conn-registry.patch.
+
+ -- Thomas Goirand <[email protected]>  Sun, 05 Apr 2026 16:42:49 +0200
+
 glance (2:30.0.0-3) unstable; urgency=medium
 
   * Fix uwsgi config.
diff -Nru 
glance-30.0.0/debian/patches/OSSA-2026-004_Fix_SSRF_vulnerabilities_in_image_import_API.patch
 
glance-30.0.0/debian/patches/OSSA-2026-004_Fix_SSRF_vulnerabilities_in_image_import_API.patch
--- 
glance-30.0.0/debian/patches/OSSA-2026-004_Fix_SSRF_vulnerabilities_in_image_import_API.patch
       1970-01-01 01:00:00.000000000 +0100
+++ 
glance-30.0.0/debian/patches/OSSA-2026-004_Fix_SSRF_vulnerabilities_in_image_import_API.patch
       2026-04-05 16:42:49.000000000 +0200
@@ -0,0 +1,1268 @@
+Description: OSSA-2026-004 Fix SSRF vulnerabilities in image import API
+ Fixed Server-Side Request Forgery (SSRF) vulnerabilities in Glance's image
+ import functionality that could allow attackers to bypass URL validation
+ and access internal resources.
+ .
+ The fix includes:
+ - IP address validation using Python's ipaddress module to reject encoded
+   IP formats (decimal, hexadecimal, octal) that could bypass blacklist checks
+ - HTTP redirect validation for web-download, glance-download, and OVF
+   processing to prevent redirect-based SSRF attacks
+ - URI validation for OVF processing which previously had no protection
+ .
+ The implementation uses Python's built-in ipaddress module which inherently
+ rejects all non-standard IP encodings and only accepts standard formats,
+ providing robust protection against IP encoding bypass attacks.
+ .
+Author: Abhishek Kekane <[email protected]>
+Date: Tue, 20 Jan 2026 19:02:08 +0000
+Assisted-by: Used Cursor (Auto) for unit tests.
+Bug: https://launchpad.net/bugs/2138602
+Bug: https://launchpad.net/bugs/2138672
+Bug: https://launchpad.net/bugs/2138675
+Bug-Debian: https://bugs.debian.org/1131274
+Change-Id: Ib8d337dc68411d18c70d5712cc4f0986ef6205f4
+Signed-off-by: Abhishek Kekane <[email protected]>
+Origin: upstream, https://review.opendev.org/c/openstack/glance/+/981298
+Last-Update: 2026-03-19
+
+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
+@@ -24,6 +24,7 @@ from taskflow.patterns import linear_flo
+ from glance.async_.flows._internal_plugins import base_download
+ 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
+ 
+@@ -66,7 +67,9 @@ class _DownloadGlanceImage(base_download
+             token = self.context.auth_token
+             request = urllib.request.Request(image_download_url,
+                                              headers={'X-Auth-Token': token})
+-            data = urllib.request.urlopen(request)
++            opener = urllib.request.build_opener(
++                script_utils.SafeRedirectHandler)
++            data = opener.open(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,7 +815,9 @@ class _ImportMetadata(task.Task):
+             token = self.context.auth_token
+             request = urllib.request.Request(image_download_metadata_url,
+                                              headers={'X-Auth-Token': token})
+-            with urllib.request.urlopen(request) as payload:
++            opener = urllib.request.build_opener(
++                script_utils.SafeRedirectHandler)
++            with opener.open(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
+@@ -18,6 +18,7 @@ import re
+ import shutil
+ import tarfile
+ import urllib
++import urllib.request
+ 
+ from defusedxml import ElementTree as etree
+ 
+@@ -27,6 +28,9 @@ 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__)
+@@ -75,7 +79,13 @@ class _OVF_Process(task.Task):
+             uri = uri.split("file://")[-1]
+             return open(uri, "rb")
+ 
+-        return urllib.request.urlopen(uri)
++        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)
+ 
+     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
+@@ -19,14 +19,18 @@ __all__ = [
+     'set_base_image_properties',
+     'validate_location_uri',
+     'get_image_data_iter',
++    'SafeRedirectHandler',
+ ]
+ 
+ import urllib
++import urllib.error
++import urllib.request
+ 
+ from oslo_log import log as logging
+ from oslo_utils import timeutils
+ 
+ from glance.common import exception
++from glance.common import utils as common_utils
+ from glance.i18n import _, _LE
+ 
+ LOG = logging.getLogger(__name__)
+@@ -125,6 +129,15 @@ def validate_location_uri(location):
+         raise urllib.error.URLError(msg)
+ 
+ 
++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):
++            msg = (_("Redirect to disallowed URL: %s") % newurl)
++            raise exception.ImportTaskError(msg)
++        return super().redirect_request(req, fp, code, msg, headers, newurl)
++
++
+ def get_image_data_iter(uri):
+     """Returns iterable object either for local file or uri
+ 
+@@ -148,7 +161,8 @@ def get_image_data_iter(uri):
+         # into memory. Some images may be quite heavy.
+         return open(uri, "rb")
+ 
+-    return urllib.request.urlopen(uri)
++    opener = urllib.request.build_opener(SafeRedirectHandler)
++    return opener.open(uri)
+ 
+ 
+ class CallbackIterator(object):
+Index: glance/glance/common/utils.py
+===================================================================
+--- glance.orig/glance/common/utils.py
++++ glance/glance/common/utils.py
+@@ -21,6 +21,7 @@ System-level utilities and helper functi
+ """
+ 
+ import errno
++import ipaddress
+ 
+ from eventlet.green import socket
+ 
+@@ -126,6 +127,65 @@ CONF.import_group('import_filtering_opts
+                   'glance.async_.flows._internal_plugins')
+ 
+ 
++def normalize_hostname(host):
++    """Normalize IP address to standard format or return hostname.
++
++    Uses ipaddress module to validate and normalize IP addresses, rejecting
++    encoded formats. For hostnames, requires DNS resolution to ensure they
++    are valid and not encoded IP attempts.
++
++    :param host: hostname or IP address
++    :returns: normalized IP address, hostname unchanged, or None
++    """
++    if not host:
++        return host
++
++    # NOTE(abhishekk): Try to parse as IPv4. ipaddress module only accepts
++    # standard format like 127.0.0.1. It rejects encoded formats like
++    # decimal (2130706433), hex (0x7f000001), or octal (017700000001).
++    try:
++        return str(ipaddress.IPv4Address(host))
++    except ValueError:
++        pass
++
++    # NOTE(abhishekk): Try to parse as IPv6. ipaddress module only accepts
++    # standard IPv6 format and rejects encoded formats.
++    try:
++        return str(ipaddress.IPv6Address(host))
++    except ValueError:
++        pass
++
++    # NOTE(abhishekk): Not valid IP address, check as hostname. Reject pure
++    # numeric strings like "2130706433" (decimal encoded IP). ipaddress module
++    # rejected it, but OS might still resolve using inet_aton() if not 
blocked.
++    if host.isdigit():
++        return None
++
++    # NOTE(abhishekk): Reject all numeric strings with dots like "127.1" or
++    # "10.1". These are shorthand IP addresses. ipaddress module rejects them
++    # because they need 4 octets, but OS may still resolve them. We block to
++    # prevent SSRF bypass attacks.
++    if all(c.isdigit() or c == '.' for c in host):
++        return None
++
++    # NOTE(abhishekk): Add trailing dot to force DNS lookup instead of numeric
++    # parsing. This blocks encoded IP formats like 0x7f000001 or 127.0x0.0.1
++    # because they fail DNS lookup. Only real hostnames that resolve via DNS
++    # are allowed.
++    testhost = host
++    if not testhost.endswith('.'):
++        testhost += '.'
++
++    try:
++        socket.getaddrinfo(testhost, 80)
++    except socket.gaierror:
++        # NOTE(abhishekk): DNS resolution failed, reject the hostname
++        return None
++
++    # NOTE(abhishekk): Valid and resolvable hostname, return unchanged
++    return host
++
++
+ def validate_import_uri(uri):
+     """Validate requested uri for Image Import web-download.
+ 
+@@ -163,9 +223,14 @@ 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
+-    if not host or ((wl_hosts and host not in wl_hosts) or
+-                    host in bl_hosts):
++
++    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
++
+     if port and ((wl_ports and port not in wl_ports) or
+                  port in bl_ports):
+         return False
+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
+@@ -15,9 +15,11 @@
+ 
+ import hashlib
+ import http.client as http
++import http.server as http_server
+ import os
+ import subprocess
+ import tempfile
++import threading
+ import time
+ import urllib
+ import uuid
+@@ -383,6 +385,164 @@ class TestImages(functional.FunctionalTe
+ 
+         self.stop_servers()
+ 
++    def test_web_download_redirect_validation(self):
++        """Test that redirect destinations are validated."""
++        self.config(allowed_ports=[80], group='import_filtering_opts')
++        self.config(disallowed_hosts=['127.0.0.1'],
++                    group='import_filtering_opts')
++        self.start_servers(**self.__dict__.copy())
++
++        # Create an image
++        path = self._url('/v2/images')
++        headers = self._headers({'content-type': 'application/json'})
++        data = jsonutils.dumps({
++            'name': 'redirect-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 = jsonutils.loads(response.text)
++        image_id = image['id']
++
++        # Try to import with redirect to disallowed host
++        # The redirect destination should be validated and rejected
++        # Create a local redirect server
++        def _get_redirect_handler_class():
++            class RedirectHTTPRequestHandler(
++                    http_server.BaseHTTPRequestHandler):
++                def do_GET(self):
++                    self.send_response(http.FOUND)
++                    self.send_header('Location', 'http://127.0.0.1:80/')
++                    self.end_headers()
++                    return
++
++                def log_message(self, *args, **kwargs):
++                    return
++
++        server_address = ('127.0.0.1', 0)
++        handler_class = _get_redirect_handler_class()
++        redirect_httpd = http_server.HTTPServer(server_address, handler_class)
++        redirect_port = redirect_httpd.socket.getsockname()[1]
++        redirect_thread = 
threading.Thread(target=redirect_httpd.serve_forever)
++        redirect_thread.daemon = True
++        redirect_thread.start()
++
++        redirect_uri = 'http://127.0.0.1:%s/' % redirect_port
++        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': redirect_uri
++        }})
++
++        # Import request may be accepted (202) but should fail during
++        # processing when redirect destination is validated
++        response = requests.post(path, headers=headers, data=data)
++        self.assertEqual(http.ACCEPTED, response.status_code)
++
++        # Clean up redirect server
++        redirect_httpd.shutdown()
++        redirect_httpd.server_close()
++
++        # Wait for the task to process and verify it failed
++        # The redirect validation in get_image_data_iter should prevent
++        # access to disallowed host (127.0.0.1)
++        time.sleep(5)  # Give task time to process
++
++        # Verify the task failed due to redirect validation
++        path = self._url('/v2/images/%s/tasks' % image_id)
++        response = requests.get(path, headers=self._headers())
++        self.assertEqual(http.OK, response.status_code)
++        tasks = jsonutils.loads(response.text)['tasks']
++        tasks = sorted(tasks, key=lambda t: t['updated_at'])
++        self.assertGreater(len(tasks), 0)
++        task = tasks[-1]
++        self.assertEqual('failure', task['status'])
++
++        # Verify the image status is still queued (not active)
++        # since the import failed - this proves the SSRF was prevented
++        path = self._url('/v2/images/%s' % image_id)
++        response = requests.get(path, headers=self._headers())
++        self.assertEqual(http.OK, response.status_code)
++        image = jsonutils.loads(response.text)
++        self.assertEqual('queued', image['status'])
++        # Image should not have checksum or size since import failed
++        # If checksum/size exist, it means data was downloaded (SSRF 
succeeded)
++        self.assertIsNone(image.get('checksum'))
++        # Image should not have size since import failed
++        self.assertIsNone(image.get('size'))
++
++        # Clean up
++        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')
++        self.config(disallowed_hosts=['127.0.0.1'],
++                    group='import_filtering_opts')
++        self.start_servers(**self.__dict__.copy())
++
++        # Create an image
++        path = self._url('/v2/images')
++        headers = self._headers({'content-type': 'application/json'})
++        data = jsonutils.dumps({
++            'name': 'ip-encoding-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 = jsonutils.loads(response.text)
++        image_id = image['id']
++
++        # Test that encoded IP (2130706433 = 127.0.0.1) is blocked
++        # after normalization
++        encoded_ip_uri = 'http://2130706433:80/'
++        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': encoded_ip_uri
++        }})
++        # Should be rejected because encoded IP normalizes to 127.0.0.1
++        # which is in disallowed_hosts
++        # Validation happens at API level, so should return 400
++        response = requests.post(path, headers=headers, data=data)
++        # Should be rejected with 400 Bad Request
++        self.assertEqual(400, response.status_code)
++
++        # Test octal integer encoded IP (017700000001 = 127.0.0.1)
++        octal_int_uri = 'http://017700000001:80/'
++        data = jsonutils.dumps({'method': {
++            'name': 'web-download',
++            'uri': octal_int_uri
++        }})
++        response = requests.post(path, headers=headers, data=data)
++        self.assertEqual(400, response.status_code)
++
++        # Test octal dotted-decimal encoded IP (0177.0.0.01 = 127.0.0.1)
++        octal_dotted_uri = 'http://0177.0.0.01:80/'
++        data = jsonutils.dumps({'method': {
++            'name': 'web-download',
++            'uri': octal_dotted_uri
++        }})
++        response = requests.post(path, headers=headers, data=data)
++        self.assertEqual(400, response.status_code)
++
++        # Clean up
++        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_image_lifecycle(self):
+         # Image list should be empty
+         self.api_server.show_multiple_locations = True
+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,6 +25,7 @@ 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
+@@ -1176,6 +1177,11 @@ class TestImportMetadata(test_utils.Base
+             'extra_metadata': 'hello',
+             '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
+         task = import_flow._ImportMetadata(TASK_ID1, TASK_TYPE,
+                                            self.context, self.wrapper,
+                                            self.import_req)
+@@ -1184,6 +1190,7 @@ class TestImportMetadata(test_utils.Base
+             'https://other.cloud.foo/image/v2/images/%s' % (
+                 IMAGE_ID1),
+             headers={'X-Auth-Token': self.context.auth_token})
++        mock_request.build_opener.assert_called_once()
+         mock_gge.assert_called_once_with(self.context, 'RegionTwo', 'public')
+         action.set_image_attribute.assert_called_once_with(
+             disk_format='qcow2',
+@@ -1212,8 +1219,11 @@ class TestImportMetadata(test_utils.Base
+     @mock.patch('glance.async_.utils.get_glance_endpoint')
+     def test_execute_fail_remote_glance_unreachable(self, mock_gge, mock_r):
+         action = self.wrapper.__enter__.return_value
+-        mock_r.urlopen.side_effect = urllib.error.HTTPError(
++        mock_gge.return_value = 'https://other.cloud.foo/image'
++        mock_opener = mock.MagicMock()
++        mock_opener.open.side_effect = urllib.error.HTTPError(
+             '/file', 400, 'Test Fail', {}, None)
++        mock_r.build_opener.return_value = mock_opener
+         task = import_flow._ImportMetadata(TASK_ID1, TASK_TYPE,
+                                            self.context, self.wrapper,
+                                            self.import_req)
+@@ -1231,6 +1241,11 @@ class TestImportMetadata(test_utils.Base
+         mock_json.loads.return_value = {
+             'status': 'queued',
+         }
++        mock_opener = mock.MagicMock()
++        mock_payload = mock.MagicMock()
++        mock_payload.read.return_value = b'{"status": "queued"}'
++        mock_opener.open.return_value.__enter__.return_value = mock_payload
++        mock_request.build_opener.return_value = mock_opener
+         task = import_flow._ImportMetadata(TASK_ID1, TASK_TYPE,
+                                            self.context, self.wrapper,
+                                            self.import_req)
+@@ -1254,6 +1269,11 @@ class TestImportMetadata(test_utils.Base
+             'os_hash': 'hash',
+             'extra_metadata': 'hello',
+         }
++        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
+         task = import_flow._ImportMetadata(TASK_ID1, TASK_TYPE,
+                                            self.context, self.wrapper,
+                                            self.import_req)
+@@ -1262,6 +1282,7 @@ class TestImportMetadata(test_utils.Base
+             'https://other.cloud.foo/image/v2/images/%s' % (
+                 IMAGE_ID1),
+             headers={'X-Auth-Token': self.context.auth_token})
++        mock_request.build_opener.assert_called_once()
+         mock_gge.assert_called_once_with(self.context, 'RegionTwo', 'public')
+         action.set_image_attribute.assert_called_once_with(
+             disk_format='qcow2',
+@@ -1271,6 +1292,71 @@ class TestImportMetadata(test_utils.Base
+             'os_hash': 'hash'
+         })
+ 
++    @mock.patch('urllib.request')
++    @mock.patch('glance.async_.utils.get_glance_endpoint')
++    def test_import_metadata_redirect_validation(self, mock_gge,
++                                                 mock_request):
++        """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(
++            "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")
++
++    @mock.patch('urllib.request')
++    @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."""
++        mock_gge.return_value = 'https://other.cloud.foo/image'
++        mock_json.loads.return_value = {
++            'status': 'active',
++            'disk_format': 'qcow2',
++            '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
++        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)
++        self.assertEqual(12345, result)
++
+     def test_revert_rollback_metadata_value(self):
+         action = self.wrapper.__enter__.return_value
+         task = import_flow._ImportMetadata(TASK_ID1, TASK_TYPE,
+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
+@@ -22,7 +22,8 @@ from oslo_utils.fixture import uuidsenti
+ 
+ from glance.async_.flows._internal_plugins import glance_download
+ from glance.async_.flows import api_image_import
+-import glance.common.exception
++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
+@@ -72,37 +73,49 @@ class TestGlanceDownloadTask(test_utils.
+         self.image_repo.get.return_value = mock.MagicMock(
+             extra_properties={'os_glance_import_task': self.task_id})
+ 
++    @mock.patch('glance.common.utils.socket.getaddrinfo')
+     @mock.patch.object(filesystem.Store, 'add')
+     @mock.patch('glance.async_.utils.get_glance_endpoint')
+-    def test_glance_download(self, mock_gge, mock_add):
++    def test_glance_download(self, mock_gge, mock_add, mock_getaddrinfo):
++        mock_getaddrinfo.return_value = [('', '', '', '', ('', 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')
+         with mock.patch('urllib.request') as mock_request:
++            mock_opener = mock.MagicMock()
++            mock_response = mock.MagicMock()
++            mock_opener.open.return_value = mock_response
++            mock_request.build_opener.return_value = mock_opener
+             mock_add.return_value = ["path", 12345]
+             self.assertEqual(glance_download_task.execute(12345), "path")
+             mock_add.assert_called_once_with(
+                 self.image_id,
+-                mock_request.urlopen.return_value, 0)
++                mock_response, 0)
+             mock_request.Request.assert_called_once_with(
+                 'https://other.cloud.foo/image/v2/images/%s/file' % (
+                     uuidsentinel.remote_image),
+                 headers={'X-Auth-Token': self.context.auth_token})
++            mock_request.build_opener.assert_called_once()
+         mock_gge.assert_called_once_with(self.context, 'RegionTwo', 'public')
+ 
++    @mock.patch('glance.common.utils.socket.getaddrinfo')
+     @mock.patch.object(filesystem.Store, 'add')
+     @mock.patch('glance.async_.utils.get_glance_endpoint')
+-    def test_glance_download_failed(self, mock_gge, mock_add):
++    def test_glance_download_failed(self, mock_gge, mock_add,
++                                    mock_getaddrinfo):
++        mock_getaddrinfo.return_value = [('', '', '', '', ('', 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')
+         with mock.patch('urllib.request') as mock_request:
+-            mock_request.urlopen.side_effect = urllib.error.HTTPError(
++            mock_opener = mock.MagicMock()
++            mock_opener.open.side_effect = urllib.error.HTTPError(
+                 '/file', 400, 'Test Fail', {}, None)
++            mock_request.build_opener.return_value = mock_opener
+             self.assertRaises(urllib.error.HTTPError,
+                               glance_download_task.execute,
+                               12345)
+@@ -127,21 +140,28 @@ class TestGlanceDownloadTask(test_utils.
+                           glance_download_task.execute, 12345)
+         mock_request.assert_not_called()
+ 
++    @mock.patch('glance.common.utils.socket.getaddrinfo')
+     @mock.patch.object(filesystem.Store, 'add')
+     @mock.patch('glance.async_.utils.get_glance_endpoint')
+-    def test_glance_download_size_mismatch(self, mock_gge, mock_add):
++    def test_glance_download_size_mismatch(self, mock_gge, mock_add,
++                                           mock_getaddrinfo):
++        mock_getaddrinfo.return_value = [('', '', '', '', ('', 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')
+         with mock.patch('urllib.request') as mock_request:
++            mock_opener = mock.MagicMock()
++            mock_response = mock.MagicMock()
++            mock_opener.open.return_value = mock_response
++            mock_request.build_opener.return_value = mock_opener
+             mock_add.return_value = ["path", 1]
+             self.assertRaises(glance.common.exception.ImportTaskError,
+                               glance_download_task.execute, 12345)
+             mock_add.assert_called_once_with(
+                 self.image_id,
+-                mock_request.urlopen.return_value, 0)
++                mock_response, 0)
+             mock_request.Request.assert_called_once_with(
+                 'https://other.cloud.foo/image/v2/images/%s/file' % (
+                     uuidsentinel.remote_image),
+@@ -165,3 +185,70 @@ class TestGlanceDownloadTask(test_utils.
+         mock_validate.assert_called_once_with(
+             'https://other.cloud.foo/image/v2/images/%s/file' % (
+                 uuidsentinel.remote_image))
++
++    @mock.patch('glance.common.utils.socket.getaddrinfo')
++    @mock.patch('urllib.request')
++    @mock.patch('glance.async_.utils.get_glance_endpoint')
++    def test_glance_download_redirect_validation(self, mock_gge,
++                                                 mock_request,
++                                                 mock_getaddrinfo):
++        """Test redirect destinations are validated during image download."""
++        mock_getaddrinfo.return_value = [('', '', '', '', ('', 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(
++            "Redirect to disallowed URL: http://127.0.0.1:5000/";)
++        mock_request.build_opener.return_value = mock_opener
++        self.assertRaises(exception.ImportTaskError,
++                          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.patch('glance.common.utils.socket.getaddrinfo')
++    @mock.patch.object(filesystem.Store, 'add')
++    @mock.patch('urllib.request')
++    @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."""
++        mock_getaddrinfo.return_value = [('', '', '', '', ('', 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_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)
++        self.assertEqual("path", result)
+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
+@@ -17,7 +17,6 @@ import io
+ import json
+ import os
+ from unittest import mock
+-import urllib
+ 
+ import glance_store
+ from oslo_concurrency import processutils as putils
+@@ -371,9 +370,15 @@ class TestImportTask(test_utils.BaseTest
+         self.img_repo.get.return_value = self.image
+         img_factory.new_image.side_effect = create_image
+ 
+-        with mock.patch.object(urllib.request, 'urlopen') as umock:
+-            content = b"TEST_IMAGE"
+-            umock.return_value = io.BytesIO(content)
++        # Mock get_image_data_iter to avoid actual network calls
++        # and to work with our SafeRedirectHandler changes
++        content = b"TEST_IMAGE"
++        mock_response = io.BytesIO(content)
++        mock_response.headers = {}
++
++        with mock.patch(
++                'glance.common.scripts.utils.get_image_data_iter') as umock:
++            umock.return_value = mock_response
+ 
+             with mock.patch.object(import_flow, "_get_import_flows") as imock:
+                 imock.return_value = (x for x in [])
+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,10 +18,12 @@ import shutil
+ import tarfile
+ import tempfile
+ from unittest import mock
++import urllib.error
+ 
+ from defusedxml.ElementTree import ParseError
+ 
+ from glance.async_.flows import ovf_process
++from glance.common import exception
+ import glance.tests.utils as test_utils
+ from oslo_config import cfg
+ 
+@@ -164,3 +166,56 @@ class TestOvfProcessTask(test_utils.Base
+         iextractor = ovf_process.OVAImageExtractor()
+         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
++        oprocess = ovf_process._OVF_Process('task_id', 'ovf_proc',
++                                            self.img_repo)
++        self.assertRaises(exception.ImportTaskError,
++                          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.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_response = mock.MagicMock()
++        mock_opener.open.return_value = mock_response
++        mock_request.build_opener.return_value = mock_opener
++        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.patch('urllib.request')
++    @mock.patch('glance.common.utils.validate_import_uri')
++    def test_get_ova_iter_objects_redirect_validation(self, mock_validate,
++                                                      mock_request):
++        """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(
++            "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,
++                          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()
+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
+@@ -15,6 +15,8 @@
+ 
+ from unittest import mock
+ import urllib
++import urllib.error
++import urllib.request
+ 
+ from glance.common import exception
+ from glance.common.scripts import utils as script_utils
+@@ -230,3 +232,110 @@ class TestCallbackIterator(test_utils.Ba
+         # call the callback with that.
+         callback.assert_has_calls([mock.call(2, 2),
+                                    mock.call(1, 3)])
++
++
++class TestSafeRedirectHandler(test_utils.BaseTestCase):
++    """Test SafeRedirectHandler for redirect validation."""
++
++    def setUp(self):
++        super(TestSafeRedirectHandler, self).setUp()
++
++    @mock.patch('glance.common.utils.validate_import_uri')
++    def test_redirect_to_allowed_url(self, mock_validate):
++        """Test redirect to allowed URL is accepted."""
++        mock_validate.return_value = True
++        handler = script_utils.SafeRedirectHandler()
++
++        req = mock.Mock()
++        req.full_url = 'http://example.com/redirect'
++        fp = mock.Mock()
++        headers = mock.Mock()
++
++        # Redirect to allowed URL
++        # redirect_request should call super().redirect_request
++        # which returns a request
++        with mock.patch.object(urllib.request.HTTPRedirectHandler,
++                               'redirect_request') as mock_super:
++            mock_super.return_value = mock.Mock()
++            result = handler.redirect_request(
++                req, fp, 302, 'Found', headers, 'http://allowed.com/target'
++            )
++
++        mock_validate.assert_called_once_with('http://allowed.com/target')
++        # Should return a request object (not None)
++        self.assertIsNotNone(result)
++
++    @mock.patch('glance.common.utils.validate_import_uri')
++    def test_redirect_to_disallowed_url(self, mock_validate):
++        """Test redirect to disallowed URL raises error."""
++        mock_validate.return_value = False
++        handler = script_utils.SafeRedirectHandler()
++
++        req = mock.Mock()
++        req.full_url = 'http://example.com/redirect'
++        fp = mock.Mock()
++        headers = mock.Mock()
++
++        # Redirect to disallowed URL should raise ImportTaskError
++        self.assertRaises(
++            exception.ImportTaskError,
++            handler.redirect_request,
++            req, fp, 302, 'Found', headers, 'http://127.0.0.1:5000/'
++        )
++
++        mock_validate.assert_called_once_with('http://127.0.0.1:5000/')
++
++
++class TestGetImageDataIter(test_utils.BaseTestCase):
++    """Test get_image_data_iter with redirect validation."""
++
++    def setUp(self):
++        super(TestGetImageDataIter, self).setUp()
++
++    @mock.patch('builtins.open', create=True)
++    def test_get_image_data_iter_file_uri(self, mock_open):
++        """Test file:// URI handling."""
++        mock_file = mock.Mock()
++        mock_open.return_value = mock_file
++
++        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.patch('urllib.request.build_opener')
++    def test_get_image_data_iter_http_uri(self, mock_build_opener):
++        """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";)
++
++        # Should use build_opener with SafeRedirectHandler
++        mock_build_opener.assert_called_once()
++        # Check that SafeRedirectHandler was passed as an argument
++        call_args = mock_build_opener.call_args
++        # build_opener can be called with *args or keyword args
++        # Check both positional and keyword arguments
++        found_handler = False
++        if call_args.args:
++            found_handler = any(
++                isinstance(arg, script_utils.SafeRedirectHandler)
++                for arg in call_args.args)
++        if not found_handler and call_args.kwargs:
++            found_handler = any(
++                isinstance(v, script_utils.SafeRedirectHandler)
++                for v in call_args.kwargs.values())
++        # Also check if it's passed as a handler class (not instance)
++        if not found_handler:
++            found_handler = (
++                script_utils.SafeRedirectHandler in call_args.args)
++
++        self.assertTrue(
++            found_handler,
++            "SafeRedirectHandler should be passed to build_opener")
++
++        mock_opener.open.assert_called_once_with("http://example.com/image";)
++        self.assertEqual(result, mock_response)
+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
+@@ -15,6 +15,8 @@
+ #    under the License.
+ 
+ import io
++import ipaddress
++import socket
+ import tempfile
+ from unittest import mock
+ 
+@@ -1013,3 +1015,179 @@ class ImportURITestCase(test_utils.BaseT
+                         group='import_filtering_opts')
+             self.assertTrue(utils.validate_import_uri("ftp://foo.com:8484";))
+             mock_run.assert_called_once()
++
++    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.assertFalse(utils.validate_import_uri("http://127.0.0.1:80/";))
++
++        # Test that encoded IP (decimal) is rejected
++        result = utils.validate_import_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/";))
++
++        # Test with allowed host - encoded IP should still be rejected
++        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/";))
++
++    @mock.patch('glance.common.utils.socket.getaddrinfo')
++    def test_normalize_hostname(self, mock_getaddrinfo):
++        """Test the normalize_hostname function."""
++        # Test standard IPv4 - should return normalized
++        result = utils.normalize_hostname("127.0.0.1")
++        self.assertEqual(result, "127.0.0.1")
++        mock_getaddrinfo.assert_not_called()
++
++        # Test standard IPv4 with different format - should normalize
++        result = utils.normalize_hostname("192.168.1.1")
++        self.assertEqual(result, "192.168.1.1")
++        mock_getaddrinfo.assert_not_called()
++
++        # Test encoded IP (decimal) - should be rejected (fails isdigit check)
++        result = utils.normalize_hostname("2130706433")
++        self.assertIsNone(result)
++        mock_getaddrinfo.assert_not_called()
++
++        # Test hex encoded IP - should be rejected (DNS resolution fails)
++        mock_getaddrinfo.reset_mock()
++        mock_getaddrinfo.side_effect = socket.gaierror(
++            "Name or service not known")
++        result = utils.normalize_hostname("0x7f000001")
++        self.assertIsNone(result)
++        mock_getaddrinfo.assert_called_once_with("0x7f000001.", 80)
++
++        # Test octal integer encoded IP - should be rejected
++        # (fails isdigit check)
++        mock_getaddrinfo.reset_mock()
++        result = utils.normalize_hostname("017700000001")
++        self.assertIsNone(result)
++        mock_getaddrinfo.assert_not_called()
++
++        # Test octal dotted-decimal encoded IP - should be rejected
++        # (all digits/dots)
++        mock_getaddrinfo.reset_mock()
++        result = utils.normalize_hostname("0177.0.0.01")
++        self.assertIsNone(result)
++        mock_getaddrinfo.assert_not_called()
++
++        # Test mixed octal/decimal dotted-decimal - should be rejected
++        # (all digits/dots)
++        mock_getaddrinfo.reset_mock()
++        result = utils.normalize_hostname("0177.0.0.1")
++        self.assertIsNone(result)
++        mock_getaddrinfo.assert_not_called()
++
++        # Test IPv6 address - should normalize to standard format
++        mock_getaddrinfo.reset_mock()
++        result = utils.normalize_hostname("::1")
++        self.assertEqual(result, "::1")
++        mock_getaddrinfo.assert_not_called()
++
++        result = utils.normalize_hostname("2001:db8::1")
++        self.assertEqual(result, "2001:db8::1")
++        mock_getaddrinfo.assert_not_called()
++
++        # Test IPv6-mapped IPv4 - should normalize
++        result = utils.normalize_hostname("::ffff:127.0.0.1")
++        ipv6 = ipaddress.IPv6Address(result)
++        expected = ipaddress.IPv6Address("::ffff:127.0.0.1")
++        self.assertEqual(ipv6, expected)
++        mock_getaddrinfo.assert_not_called()
++
++        # Test shorthand IP addresses - should be rejected
++        mock_getaddrinfo.reset_mock()
++        result = utils.normalize_hostname("127.1")
++        self.assertIsNone(result)
++        mock_getaddrinfo.assert_not_called()
++
++        result = utils.normalize_hostname("10.1")
++        self.assertIsNone(result)
++
++        result = utils.normalize_hostname("192.168.1")
++        self.assertIsNone(result)
++
++        # Test valid hostname - should return unchanged
++        mock_getaddrinfo.reset_mock()
++        mock_getaddrinfo.side_effect = None
++        mock_getaddrinfo.return_value = [('', '', '', '', ('', 80))]
++        result = utils.normalize_hostname("example.com")
++        self.assertEqual(result, "example.com")
++        mock_getaddrinfo.assert_called_once_with("example.com.", 80)
++
++        # Test valid domain starting with digit (3m.com)
++        mock_getaddrinfo.reset_mock()
++        mock_getaddrinfo.side_effect = None
++        mock_getaddrinfo.return_value = [('', '', '', '', ('', 80))]
++        result = utils.normalize_hostname("3m.com")
++        self.assertEqual(result, "3m.com")
++        mock_getaddrinfo.assert_called_once_with("3m.com.", 80)
++
++        # Test valid domain starting with 0x (0xdeadbeef.com)
++        mock_getaddrinfo.reset_mock()
++        mock_getaddrinfo.side_effect = None
++        mock_getaddrinfo.return_value = [('', '', '', '', ('', 80))]
++        result = utils.normalize_hostname("0xdeadbeef.com")
++        self.assertEqual(result, "0xdeadbeef.com")
++        mock_getaddrinfo.assert_called_once_with(
++            "0xdeadbeef.com.", 80)
++
++        # Test invalid/unresolvable hostname - should be rejected
++        mock_getaddrinfo.reset_mock()
++        mock_getaddrinfo.side_effect = socket.gaierror(
++            "Name or service not known")
++        result = utils.normalize_hostname("invalid-hostname-12345")
++        self.assertIsNone(result)
++        mock_getaddrinfo.assert_called_once_with(
++            "invalid-hostname-12345.", 80)
++
++    def test_validate_import_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'],
++                    group='import_filtering_opts')
++        self.config(allowed_ports=[80],
++                    group='import_filtering_opts')
++        # 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/";))
++
++        # Test that IPv6 address not in blacklist is allowed
++        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/";))
++
++        # Test that IPv6 localhost can be blocked separately from IPv4
++        # This ensures IPv6 addresses are properly normalized and can be
++        # blacklisted
++        self.config(disallowed_hosts=['127.0.0.1'],
++                    group='import_filtering_opts')
++        self.config(allowed_hosts=[],  # Explicitly clear whitelist
++                    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
++        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)
++
++        # 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/";))
+Index: glance/releasenotes/notes/bug-2138602-5720ad2e501b9e57.yaml
+===================================================================
+--- /dev/null
++++ glance/releasenotes/notes/bug-2138602-5720ad2e501b9e57.yaml
+@@ -0,0 +1,94 @@
++---
++security:
++  - |
++    Fixed multiple Server-Side Request Forgery (SSRF) vulnerabilities in
++    Glance's image import functionality. These vulnerabilities could allow
++    attackers to bypass URL validation and access internal resources.
++
++    **web-download Import Method SSRF:**
++
++    The web-download import method had two SSRF vulnerabilities:
++
++    *HTTP Redirect Bypass:*
++    The web-download import method did not validate redirect destinations when
++    following HTTP redirects. An attacker could provide an initial URL that
++    passed validation, but redirect to an internal or disallowed resource that
++    would bypass the security checks. This has been fixed by implementing
++    ``SafeRedirectHandler`` that validates redirect destinations before
++    following them using the same ``validate_import_uri()`` checks as the
++    initial URL.
++
++    *IP Address Encoding Bypass:*
++    The web-download import method's URL validation could be bypassed by
++    encoding IP addresses in alternative formats (decimal integer,
++    hexadecimal, octal). For example, ``127.0.0.1`` could be encoded as
++    ``2130706433`` (decimal) or ``0x7f000001`` (hexadecimal) to bypass
++    blacklist checks. This has been fixed by implementing
++    ``normalize_hostname()`` function that uses Python's ``ipaddress`` module
++    to validate IP addresses. The ``ipaddress`` module only accepts standard
++    dotted-decimal notation for IPv4 and standard format for IPv6, 
automatically
++    rejecting all encoded formats (decimal, hexadecimal, octal). Any attempt 
to
++    use encoded IP formats is rejected, preventing SSRF bypass attacks.
++
++    **glance-download Import Method SSRF:**
++
++    The glance-download import method had redirect validation bypass
++    vulnerabilities in two steps of the import flow:
++
++    *Image Data Download:*
++    When downloading image data from a remote Glance endpoint, redirects were
++    not validated, allowing attackers to redirect to internal services.
++
++    *Metadata Fetch:*
++    When fetching image metadata from a remote Glance endpoint, redirects were
++    not validated, allowing attackers to redirect to internal services.
++
++    Both steps have been fixed by using ``SafeRedirectHandler`` to validate
++    redirect destinations before following them.
++
++    **OVF Processing SSRF:**
++
++    The OVF processing functionality had a critical SSRF vulnerability with
++    zero protection - no URI validation, no redirect validation, and no IP
++    normalization. The code directly called ``urllib.request.urlopen(uri)``
++    without any validation checks. This has been fixed by adding URI
++    validation using ``validate_import_uri()`` and redirect validation using
++    ``SafeRedirectHandler``.
++
++    **Affected Components:**
++    - ``glance.common.scripts.utils.get_image_data_iter()``
++    - ``glance.common.utils.validate_import_uri()``
++    - 
``glance.async_.flows._internal_plugins.glance_download._DownloadGlanceImage.execute()``
++    - ``glance.async_.flows.api_image_import._ImportMetadata.execute()``
++    - ``glance.async_.flows.ovf_process._OVF_Process._get_ova_iter_objects()``
++
++    **Impact:**
++    - Severity: High (web-download, glance-download), Critical (OVF 
processing)
++    - Affected Versions: All versions prior to this fix
++    - Workaround: Administrators can temporarily disable affected import
++      methods by removing them from the ``enabled_import_methods``
++      configuration option
++
++    Bugs `#2138602 <https://bugs.launchpad.net/glance/+bug/2138602>`_,
++    `#2138672 <https://bugs.launchpad.net/glance/+bug/2138672>`_,
++    `#2138675 <https://bugs.launchpad.net/glance/+bug/2138675>`_
++
++fixes:
++  - |
++    Bug 2138602_: Fixed SSRF vulnerability in web-download import method via
++    HTTP redirect bypass and IP address encoding bypass. Added redirect
++    validation using SafeRedirectHandler and IP address validation using
++    Python's ``ipaddress`` module to reject encoded IP formats and prevent
++    bypass attacks.
++
++    Bug 2138672_: Fixed SSRF vulnerability in glance-download import method
++    via HTTP redirect bypass. Added redirect validation for both image data
++    download and metadata fetch operations.
++
++    Bug 2138675_: Fixed SSRF vulnerability in OVF processing functionality
++    which lacked URI validation. Added URI validation and redirect validation
++    to prevent SSRF attacks when processing OVA files.
++
++    .. _2138602: https://bugs.launchpad.net/bugs/2138602
++    .. _2138672: https://bugs.launchpad.net/bugs/2138672
++    .. _2138675: https://bugs.launchpad.net/bugs/2138675
diff -Nru glance-30.0.0/debian/patches/series 
glance-30.0.0/debian/patches/series
--- glance-30.0.0/debian/patches/series 2025-07-12 10:29:31.000000000 +0200
+++ glance-30.0.0/debian/patches/series 2026-04-05 16:42:49.000000000 +0200
@@ -1,2 +1,3 @@
 sql_conn-registry.patch
 missing-files.patch
+OSSA-2026-004_Fix_SSRF_vulnerabilities_in_image_import_API.patch
diff -Nru glance-30.0.0/debian/patches/sql_conn-registry.patch 
glance-30.0.0/debian/patches/sql_conn-registry.patch
--- glance-30.0.0/debian/patches/sql_conn-registry.patch        2025-07-12 
10:29:31.000000000 +0200
+++ glance-30.0.0/debian/patches/sql_conn-registry.patch        2026-04-05 
16:42:49.000000000 +0200
@@ -7,7 +7,7 @@
 ===================================================================
 --- glance.orig/etc/glance-api.conf
 +++ glance/etc/glance-api.conf
-@@ -1753,6 +1753,7 @@
+@@ -1732,6 +1732,7 @@
  # The SQLAlchemy connection string to use to connect to the database. (string
  # value)
  #connection = <None>

Reply via email to