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

Hi,

[ Reason ]
I'd like to update Ironic in Bookworm to fix the 3 CVE in the
subject.

[ Impact ]
Serious security issue in public accessible clouds.

[ Tests ]
Upstream has a lot of functional tests, though I haven't run
them, the package contains unit tests that are ran at build time.

[ Risks ]
The proposed fix only contains bugfix, so it should be ok.

[ 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

[ Changes ]
Adds CVE 3 patches, plus a build dependency.

Please allow me to upload ironic/21.1.0-3+deb12u1 to bookworm.

Cheers,

Thomas Goirand (zigo)
diff -Nru ironic-21.1.0/debian/changelog ironic-21.1.0/debian/changelog
--- ironic-21.1.0/debian/changelog      2023-04-14 13:48:42.000000000 +0200
+++ ironic-21.1.0/debian/changelog      2026-04-30 10:41:21.000000000 +0200
@@ -1,3 +1,19 @@
+ironic (1:21.1.0-3+deb12u1) bookworm; urgency=medium
+
+  * CVE-2026-42510 / OSSA-2026-008: Command Injection in Ironic IPMI Console
+    Implementations. Applied upstream patch: "Shell-quote console command
+    passed to socat" (Closes: #1135255).
+  * CVE-2025-44021: Ironic fails to restrict paths used for file:// image URLs.
+    Add upstream patch: OSSA-2025-001_Disallow+unsafe_image_file_paths.patch.
+    (Closes: #1104964).
+  * Add qemu-utils as build-depends because of tests from CVE-2025-44021 fix.
+  * CVE-2026-42997 / OSSA-2026-010: Credential Forwarding to Arbitrary
+    Endpoints via Ironic’s idrac Configuration molds Feature. Add upstream
+    patch validate_molds_url_against_swift_in_keystone_catalog.patch.
+    (Closes: #1135898).
+
+ -- Thomas Goirand <[email protected]>  Thu, 30 Apr 2026 10:41:21 +0200
+
 ironic (1:21.1.0-3) unstable; urgency=medium
 
   * Build-depends on openstack-pkg-tools (>= 123~).
diff -Nru ironic-21.1.0/debian/control ironic-21.1.0/debian/control
--- ironic-21.1.0/debian/control        2023-04-14 13:48:42.000000000 +0200
+++ ironic-21.1.0/debian/control        2026-04-30 10:41:21.000000000 +0200
@@ -87,6 +87,7 @@
  python3-tz,
  python3-webob,
  python3-webtest,
+ qemu-utils,
 Build-Conflicts:
  python-pysqlite2,
 Standards-Version: 4.4.1
diff -Nru 
ironic-21.1.0/debian/patches/CVE-2025-44021_OSSA-2025-001_Disallow_unsafe_image_file_paths.patch
 
ironic-21.1.0/debian/patches/CVE-2025-44021_OSSA-2025-001_Disallow_unsafe_image_file_paths.patch
--- 
ironic-21.1.0/debian/patches/CVE-2025-44021_OSSA-2025-001_Disallow_unsafe_image_file_paths.patch
    1970-01-01 01:00:00.000000000 +0100
+++ 
ironic-21.1.0/debian/patches/CVE-2025-44021_OSSA-2025-001_Disallow_unsafe_image_file_paths.patch
    2026-04-30 10:41:21.000000000 +0200
@@ -0,0 +1,393 @@
+Description: CVE-2025-44021 / OSSA-2025-001: Disallow unsafe image file:// 
paths
+ Before this change, Ironic did not filter file:// paths when used as an
+ image source except to ensure they were a file (and not, e.g. a
+ character device). This is problematic from a security perspective
+ because you could end up with config files from well-known paths being
+ written to disk on a node.
+ .
+ The allowlist default list is huge, but it includes all known usages of
+ file:// URLs across Bifrost, Ironic, Metal3, and OpenShift in both CI
+ and default configuration.
+ .
+ For the backportable version of this patch for stable branches, we have
+ omitted the unconditional block of system paths in order to permit
+ operators using those branches to fully disable the new security
+ functionality.
+Author: Jay Faulkner <[email protected]>
+Date: Mon, 21 Apr 2025 15:57:37 -0700
+Generated-by: Jetbrains Junie
+Bug: https://launchpad.net/bugs/2107847
+Bug-Debian: https://bugs.debian.org/1104964
+Change-Id: I2fa995439ee500f9dd82ec8ccfa1a25ee8e1179c
+Origin: upstream, https://review.opendev.org/c/openstack/ironic/+/949176
+Last-Update: 2025-03-12
+
+Index: ironic/doc/source/install/standalone/enrollment.rst
+===================================================================
+--- ironic.orig/doc/source/install/standalone/enrollment.rst
++++ ironic/doc/source/install/standalone/enrollment.rst
+@@ -32,13 +32,20 @@ There are however some limitations for d
+    $ sha256sum image.qcow2
+    9f6c942ad81690a9926ff530629fb69a82db8b8ab267e2cbd59df417c1a28060  
image.qcow2
+ 
+-* :ref:`direct-deploy` started supporting ``file://`` images in the Victoria
+-  release cycle, before that only HTTP(s) had been supported.
++* If you're using :ref:`direct-deploy` with ``file://`` URLs, you have to
++  ensure the images meet all requirements:
++
++  * File images must be accessible to every conductor
++  * File images must be located in a path listed in
++    :oslo_config:option:`conductor.file_url_allowed_paths`
++  * File images must not be located in ``/dev``, ``/sys``, ``/proc``,
++    ``/etc``, ``/boot``, ``/run`` or other system paths starting with the
++    Ironic 2025.2 release.
+ 
+   .. warning::
+-     File images must be accessible to every conductor! Use a shared file
+-     system if you have more than one conductor. The ironic CLI tool will not
+-     transfer the file from a local machine to the conductor(s).
++    The Ironic CLI tool will not transfer the file from a local machine to the
++    conductor(s). Operators should use shared file systems or configuration
++    management to ensure consistent availability of images.
+ 
+ .. note::
+    The Bare Metal service tracks content changes for non-Glance images by
+Index: ironic/ironic/common/image_service.py
+===================================================================
+--- ironic.orig/ironic/common/image_service.py
++++ ironic/ironic/common/image_service.py
+@@ -242,14 +242,32 @@ class FileImageService(BaseImageService)
+ 
+         :param image_href: Image reference.
+         :raises: exception.ImageRefValidationFailed if source image file
+-            doesn't exist.
+-        :returns: Path to image file if it exists.
++            doesn't exist, is in a blocked path, or is not in an allowed path.
++        :returns: Path to image file if it exists and is allowed.
+         """
+         image_path = urlparse.urlparse(image_href).path
++
++        # Check if the path is in the blocklist
++        rpath = os.path.abspath(image_path)
++
++        # Check if the path is in the allowlist
++        for allowed in CONF.conductor.file_url_allowed_paths:
++            if rpath == allowed or rpath.startswith(allowed + os.sep):
++                break
++        else:
++            raise exception.ImageRefValidationFailed(
++                image_href=image_href,
++                reason=_(
++                    "Security: Path %s is not allowed for image source "
++                    "file URLs" % image_path)
++            )
++
++        # Check if the file exists
+         if not os.path.isfile(image_path):
+             raise exception.ImageRefValidationFailed(
+                 image_href=image_href,
+                 reason=_("Specified image file not found."))
++
+         return image_path
+ 
+     def download(self, image_href, image_file):
+Index: ironic/ironic/conf/conductor.py
+===================================================================
+--- ironic.orig/ironic/conf/conductor.py
++++ ironic/ironic/conf/conductor.py
+@@ -19,6 +19,7 @@ from oslo_config import cfg
+ from oslo_config import types
+ 
+ from ironic.common.i18n import _
++from ironic.conf import types as ir_types
+ 
+ opts = [
+     cfg.IntOpt('workers_pool_size',
+@@ -384,6 +385,20 @@ opts = [
+                       'is a global setting applying to all requests this '
+                       'conductor receives, regardless of access rights. '
+                       'The concurrent clean limit cannot be disabled.')),
++    cfg.ListOpt('file_url_allowed_paths',
++                default=['/var/lib/ironic', '/shared/html', '/templates',
++                         '/opt/cache/files', '/vagrant'],
++                item_type=ir_types.ExplicitAbsolutePath(),
++                help=_(
++                    'List of paths that are allowed to be used as file:// '
++                    'URLs. Files in /boot, /dev, /etc, /proc, /sys and other'
++                    'system paths are always disallowed for security reasons. 
'
++                    'Any files in this path readable by ironic may be used as 
'
++                    'an image source when deploying. Setting this value to '
++                    '"" (empty) disables file:// URL support. Paths listed '
++                    'here are validated as absolute paths and will be 
rejected'
++                    'if they contain path traversal mechanisms, such as "..".'
++                )),
+ ]
+ 
+ 
+Index: ironic/ironic/conf/types.py
+===================================================================
+--- /dev/null
++++ ironic/ironic/conf/types.py
+@@ -0,0 +1,55 @@
++# Licensed under the Apache License, Version 2.0 (the "License"); you may not
++# use this file except in compliance with the License. You may obtain a copy
++# of the License at
++#
++# http://www.apache.org/licenses/LICENSE-2.0
++#
++# Unless required by applicable law or agreed to in writing, software
++# distributed under the License is distributed on an "AS IS" BASIS,
++# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
++# See the License for the specific language governing permissions and
++# limitations under the License.
++import os
++
++from oslo_config import types
++
++
++class ExplicitAbsolutePath(types.ConfigType):
++    """Explicit Absolute path type.
++
++    Absolute path values do not get transformed and are returned as
++    strings. They are validated to ensure they are absolute paths and are 
equal
++    to os.path.abspath(value) -- protecting from path traversal issues.
++
++    Python path libraries generally define "absolute path" as anything
++    starting with a /, so tools like path.PurePath(str).is_absolute() is not
++    useful, because it will happily return that /tmp/../etc/resolv.conf is
++    absolute. This type is to be used in cases where we require the path to
++    be explicitly absolute.
++
++    :param type_name: Type name to be used in the sample config file.
++    """
++
++    def __init__(self, type_name='explicit absolute path'):
++        super().__init__(type_name=type_name)
++
++    def __call__(self, value):
++        value = str(value)
++
++        # NOTE(JayF): This removes trailing / if provided, since
++        # os.path.abspath will not return a trailing slash.
++        if len(value) > 1:
++            value = value.rstrip('/')
++        absvalue = os.path.abspath(value)
++        if value != absvalue:
++            raise ValueError('Value must be an absolute path '
++                             'containing no path traversal mechanisms. Config'
++                             f'item was: {value}, but resolved to {absvalue}')
++
++        return value
++
++    def __repr__(self):
++        return 'explicit absolute path'
++
++    def _formatter(self, value):
++        return self.quote_trailing_and_leading_space(value)
+Index: ironic/ironic/tests/unit/common/test_image_service.py
+===================================================================
+--- ironic.orig/ironic/tests/unit/common/test_image_service.py
++++ ironic/ironic/tests/unit/common/test_image_service.py
+@@ -452,20 +452,58 @@ class FileImageServiceTestCase(base.Test
+     def setUp(self):
+         super(FileImageServiceTestCase, self).setUp()
+         self.service = image_service.FileImageService()
+-        self.href = 'file:///home/user/image.qcow2'
+-        self.href_path = '/home/user/image.qcow2'
++        self.href = 'file:///var/lib/ironic/images/image.qcow2'
++        self.href_path = '/var/lib/ironic/images/image.qcow2'
+ 
+     @mock.patch.object(os.path, 'isfile', return_value=True, autospec=True)
+     def test_validate_href(self, path_exists_mock):
+         self.service.validate_href(self.href)
+         path_exists_mock.assert_called_once_with(self.href_path)
+ 
+-    @mock.patch.object(os.path, 'isfile', return_value=False, autospec=True)
++    @mock.patch.object(os.path, 'isfile', return_value=False,
++                       autospec=True)
+     def test_validate_href_path_not_found_or_not_file(self, path_exists_mock):
+         self.assertRaises(exception.ImageRefValidationFailed,
+                           self.service.validate_href, self.href)
+         path_exists_mock.assert_called_once_with(self.href_path)
+ 
++    @mock.patch.object(os.path, 'abspath', autospec=True)
++    def test_validate_href_empty_allowlist(self, abspath_mock):
++        abspath_mock.return_value = self.href_path
++        cfg.CONF.set_override('file_url_allowed_paths', [], 'conductor')
++        self.assertRaisesRegex(exception.ImageRefValidationFailed,
++                               "is not allowed for image source file URLs",
++                               self.service.validate_href, self.href)
++
++    @mock.patch.object(os.path, 'abspath', autospec=True)
++    def test_validate_href_not_in_allowlist(self, abspath_mock):
++        href = "file:///var/is/allowed/not/this/path/image.qcow2"
++        href_path = "/var/is/allowed/not/this/path/image.qcow2"
++        abspath_mock.side_effect = ['/var/lib/ironic', href_path]
++        cfg.CONF.set_override('file_url_allowed_paths', ['/var/lib/ironic'],
++                              'conductor')
++        self.assertRaisesRegex(exception.ImageRefValidationFailed,
++                               "is not allowed for image source file URLs",
++                               self.service.validate_href, href)
++
++    @mock.patch.object(os.path, 'abspath', autospec=True)
++    @mock.patch.object(os.path, 'isfile',
++                       return_value=True, autospec=True)
++    def test_validate_href_in_allowlist(self,
++                                        path_exists_mock,
++                                        abspath_mock):
++        href_dir = '/var/lib'  # self.href_path is in /var/lib/ironic/images/
++        # First call is ironic.conf.types.ExplicitAbsolutePath
++        # Second call is in validate_href()
++        abspath_mock.side_effect = [href_dir, self.href_path]
++        cfg.CONF.set_override('file_url_allowed_paths', [href_dir],
++                              'conductor')
++        result = self.service.validate_href(self.href)
++        self.assertEqual(self.href_path, result)
++        path_exists_mock.assert_called_once_with(self.href_path)
++        abspath_mock.assert_has_calls(
++            [mock.call(href_dir), mock.call(self.href_path)])
++
+     @mock.patch.object(os.path, 'getmtime', return_value=1431087909.1641912,
+                        autospec=True)
+     @mock.patch.object(os.path, 'getsize', return_value=42, autospec=True)
+Index: ironic/ironic/tests/unit/conf/test_conductor.py
+===================================================================
+--- /dev/null
++++ ironic/ironic/tests/unit/conf/test_conductor.py
+@@ -0,0 +1,34 @@
++#    Licensed under the Apache License, Version 2.0 (the "License"); you may
++#    not use this file except in compliance with the License. You may obtain
++#    a copy of the License at
++#
++#         http://www.apache.org/licenses/LICENSE-2.0
++#
++#    Unless required by applicable law or agreed to in writing, software
++#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
++#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
++#    License for the specific language governing permissions and limitations
++#    under the License.
++
++from ironic.conf import CONF
++from ironic.tests.base import TestCase
++
++
++class ValidateConductorAllowedPaths(TestCase):
++    def test_abspath_validation_bad_path_raises(self):
++        """Verifies setting a relative path raises an error via 
oslo.config."""
++        self.assertRaises(
++            ValueError,
++            CONF.set_override,
++            'file_url_allowed_paths',
++            ['bad/path'],
++            'conductor'
++        )
++
++    def test_abspath_validation_good_paths(self):
++        """Verifies setting an absolute path works via oslo.config."""
++        CONF.set_override('file_url_allowed_paths', ['/var'], 'conductor')
++
++    def test_abspath_validation_good_paths_trailing_slash(self):
++        """Verifies setting an absolute path works via oslo.config."""
++        CONF.set_override('file_url_allowed_paths', ['/var/'], 'conductor')
+Index: ironic/ironic/tests/unit/conf/test_types.py
+===================================================================
+--- /dev/null
++++ ironic/ironic/tests/unit/conf/test_types.py
+@@ -0,0 +1,63 @@
++#    Licensed under the Apache License, Version 2.0 (the "License"); you may
++#    not use this file except in compliance with the License. You may obtain
++#    a copy of the License at
++#
++#         http://www.apache.org/licenses/LICENSE-2.0
++#
++#    Unless required by applicable law or agreed to in writing, software
++#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
++#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
++#    License for the specific language governing permissions and limitations
++#    under the License.
++
++from ironic.conf import types
++from ironic.tests.base import TestCase
++
++
++class ExplicitAbsolutePath(TestCase):
++    def test_explicit_absolute_path(self):
++        """Verifies the Opt subclass used to validate absolute paths."""
++        good_paths = [
++            '/etc/passwd',  # Valid
++            '/usr/bin/python',  # Valid
++            '/home/user/file.txt',  # Valid - dot in filename allowed
++            '/var/lib/ironic/.secretdir',  # Valid - hidden directory allowed
++            '/var/lib/ironic/oslo.config',  # Valid - dots in filename allowed
++            '/tmp/',  # Valid
++            '/',  # Valid (root directory)
++            '/.hidden_root_file',  # Valid
++            '/path/including/a/numb3r',  # Valid
++            '/a/path/with/a/trailing/slash/'  # Valid
++        ]
++        bad_paths = [
++            'relative/path',  # Invalid - no leading slash
++            './file.txt',  # Invalid - relative path
++            '../file.txt',  # Invalid - relative path
++            'file.txt',  # Invalid - no leading slash
++            '',  # Invalid - empty string
++            '/var/lib/ironic/../../../etc/passwd',  # Invalid - path traversal
++            '/etc/../etc/passwd',  # Invalid - path traversal
++            '/home/user/./config',  # Invalid - contains current dir reference
++            '/home/user/../user/config',  # Invalid - path traversal
++            '/../etc/passwd',  # Invalid - path traversal at beginning
++            '/.',  # Invalid - just current directory
++            '/..'  # Invalid - just parent directory
++        ]
++
++        eap = types.ExplicitAbsolutePath()
++
++        def _trypath(tpath):
++            try:
++                eap(tpath)
++            except ValueError:
++                return False
++            else:
++                return True
++
++        for path in good_paths:
++            self.assertTrue(_trypath(path),
++                            msg=f"Improperly disallowed path: {path}")
++
++        for path in bad_paths:
++            self.assertFalse(_trypath(path),
++                             msg=f"Improperly allowed path: {path}")
+Index: 
ironic/releasenotes/notes/ossa-2025-001-disallow-unsafe-image-paths-670fdcfe3e4647d4.yaml
+===================================================================
+--- /dev/null
++++ 
ironic/releasenotes/notes/ossa-2025-001-disallow-unsafe-image-paths-670fdcfe3e4647d4.yaml
+@@ -0,0 +1,29 @@
++---
++security:
++  - |
++    Fixes OSSA-2025-001, where Ironic did not properly filter file:// paths
++    when used as image sources. This would permit any file accessible by the
++    conductor to be used as an image to attempt deployment.
++
++    Adds ``CONF.conductor.file_url_allowed_paths``, an allowlist configuration
++    defaulting to ``/var/lib/ironic``, ``/shared/html``,
++    ``/opt/cache/files``, ``/vagrant``, and  ``/templates``,
++    permits operators to further restrict where the conductor will fetch
++    images for when provided a file:// URL. This default value was chosen
++    based on known usage by projects downstream of Ironic, including Metal3,
++    Bifrost, and OpenShift. These defaults may change to be more restrictive
++    at a later date. Operators using file:// URLs are encouraged to explicitly
++    set this value even if the current default is sufficient. Operators 
wishing
++    to fully disable the ability to deploy with a file:// URL should set this
++    configuration to "" (empty).
++
++    Operators wishing to restore the original insecure behavior should set
++    ``CONF.conductor.file_url_allowed_paths`` to ``/``. Take note that in the
++    2025.2 release and later, ``/dev``, ``/sys``, ``/proc``, ``/run``, and
++    ``/etc`` will be unconditionally blocked as a security measure.
++
++    This issue only poses a significant security risk when Ironic's
++    automated cleaning process is disabled and the service is configured in
++    such a way that permits direct deployment by an untrusted API user, such 
as
++    standalone Ironic installations or environments granting ownership of 
nodes
++    to projects.
diff -Nru 
ironic-21.1.0/debian/patches/CVE-2026-42510_Shell-quote_console_command_passed_to_socat.patch
 
ironic-21.1.0/debian/patches/CVE-2026-42510_Shell-quote_console_command_passed_to_socat.patch
--- 
ironic-21.1.0/debian/patches/CVE-2026-42510_Shell-quote_console_command_passed_to_socat.patch
       1970-01-01 01:00:00.000000000 +0100
+++ 
ironic-21.1.0/debian/patches/CVE-2026-42510_Shell-quote_console_command_passed_to_socat.patch
       2026-04-30 10:41:21.000000000 +0200
@@ -0,0 +1,77 @@
+Author: Afonne-CID <[email protected]>
+Date: Wed, 22 Apr 2026 22:51:20 +0100
+Description: CVE-2026-42510: Shell-quote console command passed to `socat`
+ Applies shell quoting to console command passed to `socat`'s EXEC:.
+Bug: https://launchpad.net/bugs/2148331
+Bug-Debian: https://bugs.debian.org/1135255
+Change-Id: Ib27f8ee08184a061beef28192bce39f690c9eaa3
+Signed-off-by: Afonne-CID <[email protected]>
+Origin: upstream, https://review.opendev.org/c/openstack/ironic/+/986418
+
+diff --git a/ironic/drivers/modules/console_utils.py 
b/ironic/drivers/modules/console_utils.py
+index c5e9e85..3019866 100644
+--- a/ironic/drivers/modules/console_utils.py
++++ b/ironic/drivers/modules/console_utils.py
+@@ -23,6 +23,7 @@
+ import fcntl
+ import ipaddress
+ import os
++import shlex
+ import signal
+ import socket
+ import subprocess
+@@ -424,7 +425,8 @@
+     args.append(arg % {'host': console_host,
+                        'port': port})
+ 
+-    args.append('EXEC:"%s",pty,stderr' % console_cmd)
++    quoted_cmd = shlex.quote(console_cmd)
++    args.append('EXEC:"%s",pty,stderr' % quoted_cmd)
+ 
+     # run the command as a subprocess
+     try:
+diff --git a/ironic/tests/unit/drivers/modules/test_console_utils.py 
b/ironic/tests/unit/drivers/modules/test_console_utils.py
+index 4ce3ae4..c6d8168 100644
+--- a/ironic/tests/unit/drivers/modules/test_console_utils.py
++++ b/ironic/tests/unit/drivers/modules/test_console_utils.py
+@@ -534,13 +534,14 @@
+                        autospec=True)
+     def _test_start_socat_console_check_arg(self, mock_timer_start,
+                                             mock_stop, mock_dir_exists,
+-                                            mock_get_pid, mock_popen):
++                                            mock_get_pid, mock_popen,
++                                            console_cmd='ls&'):
+         mock_timer_start.return_value = mock.Mock()
+         mock_get_pid.return_value = '/tmp/%s.pid' % self.info['uuid']
+ 
+         console_utils.start_socat_console(self.info['uuid'],
+                                           self.info['port'],
+-                                          'ls&')
++                                          console_cmd)
+ 
+         mock_stop.assert_called_once_with(self.info['uuid'])
+         mock_dir_exists.assert_called_once_with()
+@@ -549,6 +550,12 @@
+         mock_popen.assert_called_once_with(mock.ANY, stderr=subprocess.PIPE)
+         return mock_popen.call_args[0][0]
+ 
++    def test_escape_start_socat_console_command(self):
++        command = ";cat /etc/passwd; && echo it\'s tricky"
++        quoted_command = ';cat /etc/passwd; && echo it\'"\'"\'s tricky'
++        args = self._test_start_socat_console_check_arg(console_cmd=command)
++        self.assertIn(quoted_command, args[-1])
++
+     def test_start_socat_console_check_arg_default_timeout(self):
+         args = self._test_start_socat_console_check_arg()
+         self.assertIn('-T600', args)
+diff --git 
a/releasenotes/notes/escape-socat-console-string-arguments-555388ab8dcb8cc3.yaml
 
b/releasenotes/notes/escape-socat-console-string-arguments-555388ab8dcb8cc3.yaml
+new file mode 100644
+index 0000000..cbd63db
+--- /dev/null
++++ 
b/releasenotes/notes/escape-socat-console-string-arguments-555388ab8dcb8cc3.yaml
+@@ -0,0 +1,5 @@
++---
++fixes:
++  - |
++    Fixes an issue where console command passed to `socat`'s `EXEC:` was not
++    quoted which could have security implications.
diff -Nru 
ironic-21.1.0/debian/patches/CVE-2026-42997_OSSN-2026-010_validate_molds_url_against_swift_in_keystone_catalog.patch
 
ironic-21.1.0/debian/patches/CVE-2026-42997_OSSN-2026-010_validate_molds_url_against_swift_in_keystone_catalog.patch
--- 
ironic-21.1.0/debian/patches/CVE-2026-42997_OSSN-2026-010_validate_molds_url_against_swift_in_keystone_catalog.patch
        1970-01-01 01:00:00.000000000 +0100
+++ 
ironic-21.1.0/debian/patches/CVE-2026-42997_OSSN-2026-010_validate_molds_url_against_swift_in_keystone_catalog.patch
        2026-04-30 10:41:21.000000000 +0200
@@ -0,0 +1,230 @@
+Author: Julia Kreger <[email protected]>
+Date: Tue, 28 Apr 2026 14:05:28 -0700
+Description: CVE-2026-42997 / OSSN-2026-010: security: validate molds url 
against swift in keystone catalog
+ Adds a security check to ensure the URL "netloc", i.e. hostname
+ and port, matches the user supplied URL if the configuration
+ molds feature for Dell hardware goes down the path of attempting
+ to get or set the URL. This works by sending the url to the
+ authorization code and handling checking the URL there as a
+ safety check prior to proceeding.
+Bug: https://launchpad.net/bugs/2148317
+Bug-Debian: https://bugs.debian.org/1135898
+Change-Id: I22a476dd3734ae4c1940c595f537be1c1a94acf5
+Signed-off-by: Julia Kreger <[email protected]>
+Origin: upstream, https://review.opendev.org/c/openstack/ironic/+/986817
+Last-Update: 2026-05-07
+
+diff --git a/ironic/common/molds.py b/ironic/common/molds.py
+index 234fcc6..ec61116 100644
+--- a/ironic/common/molds.py
++++ b/ironic/common/molds.py
+@@ -13,6 +13,7 @@
+ # under the License.
+ 
+ import json
++from urllib.parse import urlparse
+ 
+ from oslo_config import cfg
+ from oslo_log import log as logging
+@@ -22,6 +23,7 @@
+ 
+ from ironic.common import exception
+ from ironic.common.i18n import _
++from ironic.common import keystone
+ from ironic.common import swift
+ 
+ LOG = logging.getLogger(__name__)
+@@ -51,7 +53,7 @@
+         return requests.put(
+             url, data=json.dumps(data, indent=2), headers=auth_header)
+ 
+-    auth_header = _get_auth_header(task)
++    auth_header = _get_auth_header(task, url)
+     response = _request(url, data, auth_header)
+     response.raise_for_status()
+ 
+@@ -78,7 +80,7 @@
+     def _request(url, auth_header):
+         return requests.get(url, headers=auth_header)
+ 
+-    auth_header = _get_auth_header(task)
++    auth_header = _get_auth_header(task, url)
+     response = _request(url, auth_header)
+     if response.status_code == requests.codes.ok:
+         if not response.content:
+@@ -96,7 +98,7 @@
+     response.raise_for_status()
+ 
+ 
+-def _get_auth_header(task):
++def _get_auth_header(task, url):
+     """Based on setup of configuration mold storage gets authentication header
+ 
+     :param task: A TaskManager instance.
+@@ -105,8 +107,21 @@
+     """
+     auth_header = None
+     if CONF.molds.storage == 'swift':
++        swift_session = swift.get_swift_session()
++        # NOTE(TheJulia): First validate the URL.
++        if url:
++            endpoint = keystone.get_endpoint('swift',
++                                             session=swift_session)
++            if (not endpoint
++                    or urlparse(endpoint).netloc != urlparse(url).netloc):
++                # Raise an InvalidParameterValue should the value not
++                # match swift configuration.
++                raise exception.InvalidParameterValue(
++                    _('Supplied URL for a mold target does not match the '
++                      'swift configuration.'))
++
+         # TODO(ajya) Need to update to use Swift client and context session
+-        auth_token = swift.get_swift_session().get_token()
++        auth_token = swift_session.get_token()
+         if auth_token:
+             auth_header = {'X-Auth-Token': auth_token}
+         else:
+diff --git a/ironic/tests/unit/common/test_molds.py 
b/ironic/tests/unit/common/test_molds.py
+index 810dd61..9c57979 100644
+--- a/ironic/tests/unit/common/test_molds.py
++++ b/ironic/tests/unit/common/test_molds.py
+@@ -19,6 +19,7 @@
+ import requests
+ 
+ from ironic.common import exception
++from ironic.common import keystone
+ from ironic.common import molds
+ from ironic.common import swift
+ from ironic.conductor import task_manager
+@@ -32,15 +33,18 @@
+         super(ConfigurationMoldTestCase, self).setUp()
+         self.node = obj_utils.create_test_node(self.context)
+ 
++    @mock.patch.object(keystone, 'get_endpoint', autospec=True)
+     @mock.patch.object(swift, 'get_swift_session', autospec=True)
+     @mock.patch.object(requests, 'put', autospec=True)
+-    def test_save_configuration_swift(self, mock_put, mock_swift):
++    def test_save_configuration_swift(self, mock_put, mock_swift,
++                                      mock_endpoint):
+         mock_session = mock.Mock()
+         mock_session.get_token.return_value = 'token'
+         mock_swift.return_value = mock_session
+         cfg.CONF.set_override('storage', 'swift', 'molds')
+         url = 'https://example.com/file1'
+         data = {'key': 'value'}
++        mock_endpoint.return_value = 'https://example.com/v1'
+ 
+         with task_manager.acquire(self.context, self.node.uuid) as task:
+             molds.save_configuration(task, url, data)
+@@ -48,15 +52,18 @@
+         mock_put.assert_called_once_with(url, '{\n  "key": "value"\n}',
+                                          headers={'X-Auth-Token': 'token'})
+ 
++    @mock.patch.object(keystone, 'get_endpoint', autospec=True)
+     @mock.patch.object(swift, 'get_swift_session', autospec=True)
+     @mock.patch.object(requests, 'put', autospec=True)
+-    def test_save_configuration_swift_noauth(self, mock_put, mock_swift):
++    def test_save_configuration_swift_noauth(self, mock_put, mock_swift,
++                                             mock_endpoint):
+         mock_session = mock.Mock()
+         mock_session.get_token.return_value = None
+         mock_swift.return_value = mock_session
+         cfg.CONF.set_override('storage', 'swift', 'molds')
+         url = 'https://example.com/file1'
+         data = {'key': 'value'}
++        mock_endpoint.return_value = 'https://example.com/v1'
+ 
+         with task_manager.acquire(self.context, self.node.uuid) as task:
+             self.assertRaises(
+@@ -158,9 +165,11 @@
+                 headers={'Authorization': 'Basic dXNlcjpwYXNzd29yZA=='})
+             self.assertEqual(mock_put.call_count, 2)
+ 
++    @mock.patch.object(keystone, 'get_endpoint', autospec=True)
+     @mock.patch.object(swift, 'get_swift_session', autospec=True)
+     @mock.patch.object(requests, 'get', autospec=True)
+-    def test_get_configuration_swift(self, mock_get, mock_swift):
++    def test_get_configuration_swift(self, mock_get, mock_swift,
++                                     mock_endpoint):
+         mock_session = mock.Mock()
+         mock_session.get_token.return_value = 'token'
+         mock_swift.return_value = mock_session
+@@ -171,6 +180,7 @@
+         response.json.return_value = {'key': 'value'}
+         mock_get.return_value = response
+         url = 'https://example.com/file1'
++        mock_endpoint.return_value = 'https://example.com/v1'
+ 
+         with task_manager.acquire(self.context, self.node.uuid) as task:
+             result = molds.get_configuration(task, url)
+@@ -178,15 +188,44 @@
+             mock_get.assert_called_once_with(
+                 url, headers={'X-Auth-Token': 'token'})
+             self.assertJsonEqual({'key': 'value'}, result)
++        mock_endpoint.assert_called_once_with('swift', session=mock_session)
+ 
++    @mock.patch.object(keystone, 'get_endpoint', autospec=True)
+     @mock.patch.object(swift, 'get_swift_session', autospec=True)
+     @mock.patch.object(requests, 'get', autospec=True)
+-    def test_get_configuration_swift_noauth(self, mock_get, mock_swift):
++    def test_get_configuration_swift_url_mismatch(
++            self, mock_get, mock_swift, mock_endpoint):
++        mock_session = mock.Mock()
++        mock_session.get_token.return_value = 'token'
++        mock_swift.return_value = mock_session
++        cfg.CONF.set_override('storage', 'swift', 'molds')
++        response = mock.MagicMock()
++        response.status_code = 200
++        response.content = "{'key': 'value'}"
++        response.json.return_value = {'key': 'value'}
++        mock_get.return_value = response
++        url = 'https://example.com/file1'
++        mock_endpoint.return_value = 'https://cloud.foo/v1'
++
++        with task_manager.acquire(self.context, self.node.uuid) as task:
++            self.assertRaises(exception.InvalidParameterValue,
++                              molds.get_configuration,
++                              task, url)
++
++        mock_endpoint.assert_called_once_with('swift', session=mock_session)
++        mock_get.assert_not_called()
++
++    @mock.patch.object(keystone, 'get_endpoint', autospec=True)
++    @mock.patch.object(swift, 'get_swift_session', autospec=True)
++    @mock.patch.object(requests, 'get', autospec=True)
++    def test_get_configuration_swift_noauth(self, mock_get, mock_swift,
++                                            mock_endpoint):
+         mock_session = mock.Mock()
+         mock_session.get_token.return_value = None
+         mock_swift.return_value = mock_session
+         cfg.CONF.set_override('storage', 'swift', 'molds')
+         url = 'https://example.com/file1'
++        mock_endpoint.return_value = 'https://example.com/v1'
+ 
+         with task_manager.acquire(self.context, self.node.uuid) as task:
+             self.assertRaises(
+diff --git a/releasenotes/notes/fix-bug-2148317-471adcfac69791dc.yaml 
b/releasenotes/notes/fix-bug-2148317-471adcfac69791dc.yaml
+new file mode 100644
+index 0000000..dafc3bb
+--- /dev/null
++++ b/releasenotes/notes/fix-bug-2148317-471adcfac69791dc.yaml
+@@ -0,0 +1,19 @@
++---
++fixes:
++  - |
++    Fixes a security issue where the deprecated configuration molds feature
++    would allow an user invoking molds to request authorization
++    to be sent to a remote endpoint. This user supplied URL could be a
++    ``swift`` or ``http`` url. While when used with ``http``, the feature
++    was explicitly designed around a concept of just publishing to a file
++    in a limited context with authentication details provided by the
++    conductor, where as with ``swift`` the impact is greater because the
++    time limited session token for Ironic's access of swift resources could
++    be leaked, captured, and used.
++
++    The configuration molds feature now explicitly checks the swift endpoint
++    URL and raises an exception when the URL does not match the user supplied
++    the configured Swift endpoint.
++
++    More information can be found in
++    `bug 2148317 <https://bugs.launchpad.net/ironic/+bug/2148317>`_.
diff -Nru ironic-21.1.0/debian/patches/series 
ironic-21.1.0/debian/patches/series
--- ironic-21.1.0/debian/patches/series 2023-04-14 13:48:42.000000000 +0200
+++ ironic-21.1.0/debian/patches/series 2026-04-30 10:41:21.000000000 +0200
@@ -1,2 +1,5 @@
 adds-alembic.ini-in-MANIFEST.in.patch
 py3.11_fix_unit_tests.patch
+CVE-2025-44021_OSSA-2025-001_Disallow_unsafe_image_file_paths.patch
+CVE-2026-42510_Shell-quote_console_command_passed_to_socat.patch
+CVE-2026-42997_OSSN-2026-010_validate_molds_url_against_swift_in_keystone_catalog.patch

Reply via email to