--- Begin Message ---
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
--- End Message ---