CVE-2025-44021
Description
OpenStack Ironic before 29.0.1 can write unintended files to a target node disk during image handling (if a deployment was performed via the API). A malicious project assigned as a node owner can provide a path to any local file (readable by ironic-conductor), which may then be written to the target node disk. This is difficult to exploit in practice, because a node deployed in this manner should never reach the ACTIVE state, but it still represents a danger in environments running with non-default, insecure configurations such as with automated cleaning disabled. The fixed versions are 24.1.3, 26.1.1, and 29.0.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
OpenStack Ironic before 29.0.1 allows a malicious node owner to write arbitrary local files (readable by ironic-conductor) to a target node disk via crafted file:// image URLs.
Vulnerability
CVE-2025-44021 is a path restriction vulnerability in OpenStack Ironic's image handling. The FileImageService previously accepted arbitrary file:// URLs without validating that the referenced path was within an allowed directory. A malicious user assigned as a node owner could provide a path to any local file readable by the ironic-conductor process, such as /etc/passwd or a sensitive configuration file, which would then be written to the target node's disk during a deployment initiated directly via Ironic's API [1][2]. This behavior is not triggered when deployments are performed through Nova's virt driver.
Exploitation
Exploitation requires the attacker to be a project owner of the node and to issue a deployment request via Ironic's API. The referenced file must be readable by ironic-conductor. A node deployed through such a request should normally fail to reach the ACTIVE state, making exploitation difficult in default configurations. However, in environments where automated cleaning is disabled (a non-default, insecure setting), the flawed node can become ACTIVE, allowing the malicious file write to succeed [4]. The fix introduces a allowlist (file_url_allowed_paths) and explicitly blocks dangerous paths like /dev [3].
Impact
If successfully exploited, an attacker can write arbitrary local files from the conductor host onto the target node's disk. This could lead to further compromise, such as modifying system configuration, planting backdoors, or leaking sensitive data. The vulnerability is considered low severity (CVSS 2.8) due to the significant preconditions required and the difficulty of achieving a successful exploitation in standard deployments [2].
Mitigation
The vulnerability is fixed in Ironic versions 24.1.3, 26.1.1, and 29.0.1 [1][2][4]. Operators should upgrade to a patched release. As a workaround, disabling direct API access for untrusted users or ensuring automated cleaning remains enabled can reduce the risk. The patches enforce path validation that restricts file:// URLs to only those paths explicitly allowed in the file_url_allowed_paths configuration option [3].
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
ironicPyPI | < 24.1.3 | 24.1.3 |
ironicPyPI | >= 25.0.0, < 26.1.1 | 26.1.1 |
ironicPyPI | >= 27.0.0, < 29.0.1 | 29.0.1 |
Affected products
1Patches
110590b36f541Merge "OSSA-2025-001: Disallow unsafe image file:// paths"
8 files changed · +294 −10
doc/source/install/standalone/enrollment.rst+11 −5 modified@@ -32,13 +32,19 @@ There are however some limitations for different hardware interfaces: $ 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 not be located in ``/dev``, ``/sys``, ``/proc``, + ``/etc``, ``/boot``, ``/run`` or other system paths. + * File images must be located in a path listed in + :oslo_config:option:`conductor.file_url_allowed_paths` .. 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
ironic/common/image_service.py+32 −2 modified@@ -36,6 +36,11 @@ from ironic.conf import CONF IMAGE_CHUNK_SIZE = 1024 * 1024 # 1mb +# NOTE(JayF): This is the check-of-last-resort; we also have an allowlist +# enabled by default. These represent paths that under no circumstances should +# we access for file:// URLs +BLOCKED_FILE_URL_PATHS = {'/dev', '/sys', '/proc', '/boot', '/etc', '/run'} + LOG = log.getLogger(__name__) @@ -813,14 +818,39 @@ def validate_href(self, image_href): :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) + for bad in BLOCKED_FILE_URL_PATHS: + if rpath == bad or rpath.startswith(bad + os.sep): + raise exception.ImageRefValidationFailed( + image_href=image_href, + reason=_("Security: The path %s is not permitted in file " + "URLs" % bad) + ) + + # 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):
ironic/conf/conductor.py+15 −0 modified@@ -21,6 +21,7 @@ from ironic.common import boot_modes from ironic.common.i18n import _ from ironic.common import lessee_sources +from ironic.conf import types as ir_types opts = [ @@ -531,6 +532,20 @@ 'wish to disable this functionality, otherwise it is ' 'automatically applied by the conductor should a ' 'compressed artifact be detected.')), + 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 "..".' + )), ]
ironic/conf/types.py+55 −0 added@@ -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)
ironic/tests/unit/common/test_image_service.py+58 −3 modified@@ -615,20 +615,75 @@ class FileImageServiceTestCase(base.TestCase): 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_blocked_path(self, abspath_mock): + href = 'file:///dev/sda1' + href_path = '/dev/sda1' + href_dir = '/dev' + abspath_mock.side_effect = [href_dir, href_path] + # Explicitly allow the bad path + cfg.CONF.set_override('file_url_allowed_paths', [href_dir], + 'conductor') + + # Still raises an error because /dev is expressly forbidden + self.assertRaisesRegex(exception.ImageRefValidationFailed, + "is not permitted in file URLs", + self.service.validate_href, href) + abspath_mock.assert_has_calls( + [mock.call(href_dir), mock.call(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)
ironic/tests/unit/conf/test_conductor.py+34 −0 added@@ -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')
ironic/tests/unit/conf/test_types.py+63 −0 added@@ -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}")
releasenotes/notes/ossa-2025-001-disallow-unsafe-image-paths-670fdcfe3e4647d4.yaml+26 −0 added@@ -0,0 +1,26 @@ +--- +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. Ironic now + unconditionally forbids paths that provide access to system + configuration (/dev, /sys, /proc, /boot, /run, and /etc). + + 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). + + 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.
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-q3m2-crgq-5p3qghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-44021ghsaADVISORY
- www.openwall.com/lists/oss-security/2025/05/08/1nvdWEB
- bugs.launchpad.net/ironic/+bug/2107847nvdWEB
- github.com/openstack/ironic/commit/10590b36f541130f6a5d7a49da0f095ff8390cceghsaWEB
- github.com/pypa/advisory-database/tree/main/vulns/ironic/PYSEC-2025-38.yamlghsaWEB
- security.openstack.org/ossa/OSSA-2025-001.htmlnvdWEB
News mentions
0No linked articles in our index yet.