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

