Version in base suite: 29.0.5-0+deb13u2 Base version: ironic_29.0.5-0+deb13u2 Target version: ironic_29.0.5-0+deb13u3 Base file: /srv/ftp-master.debian.org/ftp/pool/main/i/ironic/ironic_29.0.5-0+deb13u2.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/i/ironic/ironic_29.0.5-0+deb13u3.dsc changelog | 29 patches/CVE-2026-43003_Add_an_agent_flag_to_disable_installing_boatloaders.patch | 82 patches/CVE-2026-44918-Prevent_rehoming_resources_to_nodes_with_different_owner.patch | 737 ++++++++ patches/CVE-2026-46447_erata1_Fix_kernel_parameter_parsing_for_quoted_values_and_whitespace.patch | 145 + patches/CVE-2026-54421_Fix_sensitive_properties_returned_on_volume_targets.patch | 298 +++ patches/CVE-2026-54423-Add_operator_configurable_step_disallow_lists.patch | 921 ++++++++++ patches/CVE-2026-54423-block_vendor.send_raw.patch | 214 ++ patches/OSSN-0106_Add_api_enable_ramdisk_endpoints_config_option.patch | 188 ++ patches/series | 7 9 files changed, 2621 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpvwybmo08/ironic_29.0.5-0+deb13u2.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpvwybmo08/ironic_29.0.5-0+deb13u3.dsc: no acceptable signature found diff -Nru ironic-29.0.5/debian/changelog ironic-29.0.5/debian/changelog --- ironic-29.0.5/debian/changelog 2026-06-01 07:59:53.000000000 +0000 +++ ironic-29.0.5/debian/changelog 2026-06-16 10:46:11.000000000 +0000 @@ -1,3 +1,32 @@ +ironic (1:29.0.5-0+deb13u3) trixie-security; urgency=medium + + * Add follow-up patch for CVE-2026-46447 (erata1): "Fix kernel parameter + parsing for quoted values and whitespace". + * CVE-2026-54421: Sensitive properties returned unredacted in POST and PATCH + HTTP responses. Added upstream patch: "Fix sensitive properties returned on + volume targets" (Closes: #1140012). + * CVE-2026-43003 / OSSN-2026-0100: Command injection via chroot execution of + tenant-controlled binaries. Added upstream patch: "Add an agent flag to + disable installing boatloaders" (Closes: #1140187). + * CVE-2026-44918: multiple related vulnerabilities in Ironic RBAC. An + authenticated project manager can change the node associated with Volume + Connectors or Volume Target objects, potentially changing the project + permitted to access the object. Volume Connectors contain secrets in + environments configuring boot from volume with iSCSI volumes. Applied + upstream patch: "Prevent rehoming resources to nodes with different owner". + (Closes: #1141716). + * CVE-2026-54423: A malicious user with access to deploy a node directly via + Ironic can specify the IPMI `send_raw` deployment step with a malicious + payload and send commands to that nodes' BMC. Applied upstream patches: + - Add operator-configurable step disallow lists + - block vendor.send_raw + (Closes: #1141717). + * OSSN-0106: API ramdisk endpoints require network-level access controls. + Added upstream patch: "Add [api] enable_ramdisk_endpoints config option" + (Closes: #1144214). + + -- Thomas Goirand Tue, 16 Jun 2026 12:46:11 +0200 + ironic (1:29.0.5-0+deb13u2) trixie-security; urgency=medium * CVE-2026-44917: Ironic does not validate the location of diff -Nru ironic-29.0.5/debian/patches/CVE-2026-43003_Add_an_agent_flag_to_disable_installing_boatloaders.patch ironic-29.0.5/debian/patches/CVE-2026-43003_Add_an_agent_flag_to_disable_installing_boatloaders.patch --- ironic-29.0.5/debian/patches/CVE-2026-43003_Add_an_agent_flag_to_disable_installing_boatloaders.patch 1970-01-01 00:00:00.000000000 +0000 +++ ironic-29.0.5/debian/patches/CVE-2026-43003_Add_an_agent_flag_to_disable_installing_boatloaders.patch 2026-06-16 10:46:11.000000000 +0000 @@ -0,0 +1,82 @@ +Author: Clif Houck +Date: Fri, 29 May 2026 11:34:25 -0500 +Description: [PATCH] Add an agent flag to disable installing boatloaders + Pipes the new agent flag (enable_bios_bootloader_install) to agents. + This flag disables bootloader install (calls to grub-install) by default + for security reasons. + . + Part of mitigation of CVE-2026-43003. + . + Changes to backported versions: The default flips to avoid breaking + stable users of Ironic. While this is a less secure default, it allows + operators to apply the patch without fear of breaking existing + workloads. Operators who need the increased security posture should + explicitly set [agent]/enable_bios_bootloader_install to False. +Bug: https://launchpad.net/bugs/2148310 +Bug-Debian: https://bugs.debian.org/1140187 +Change-Id: I694bbe121e09e7e0b2e6c5ab3746f7943385190a +Signed-off-by: Clif Houck +Signed-off-by: Jay Faulkner +Origin: upstream, https://review.opendev.org/c/openstack/ironic/+/993684 +Last-Update: 2026-06-18 + +diff --git a/ironic/api/controllers/v1/ramdisk.py b/ironic/api/controllers/v1/ramdisk.py +index 8ae9e1f..2dcb263 100644 +--- a/ironic/api/controllers/v1/ramdisk.py ++++ b/ironic/api/controllers/v1/ramdisk.py +@@ -65,6 +65,7 @@ + 'agent_md5_checksum_enable': CONF.agent.allow_md5_checksum, + 'disable_deep_image_inspection': CONF.conductor.disable_deep_image_inspection, # noqa + 'permitted_image_formats': CONF.conductor.permitted_image_formats, ++ 'enable_bios_bootloader_install': CONF.agent.enable_bios_bootloader_install, # noqa + } + + +diff --git a/ironic/conf/agent.py b/ironic/conf/agent.py +index 9cea41d..97008ad 100644 +--- a/ironic/conf/agent.py ++++ b/ironic/conf/agent.py +@@ -180,6 +180,14 @@ + 'permitted to consider MD5 checksums. This option ' + 'is expected to change to a default of False in a ' + '2024 release of Ironic.')), ++ cfg.BoolOpt('enable_bios_bootloader_install', ++ default=True, ++ help=_('When enabled, enables agent support for partition ' ++ 'images which require a legacy bootloader -- and a ' ++ 'call to ``grub-install``. Generally, this should ' ++ 'remain disabled for maximum security, however, this ' ++ 'option allows it to be re-enabled for ' ++ 'compatibility.')), + ] + + +diff --git a/ironic/tests/unit/api/controllers/v1/test_ramdisk.py b/ironic/tests/unit/api/controllers/v1/test_ramdisk.py +index 5b19f43..a1d14ec 100644 +--- a/ironic/tests/unit/api/controllers/v1/test_ramdisk.py ++++ b/ironic/tests/unit/api/controllers/v1/test_ramdisk.py +@@ -84,6 +84,7 @@ + 'agent_md5_checksum_enable': CONF.agent.allow_md5_checksum, + 'disable_deep_image_inspection': CONF.conductor.disable_deep_image_inspection, # noqa + 'permitted_image_formats': CONF.conductor.permitted_image_formats, ++ 'enable_bios_bootloader_install': CONF.agent.enable_bios_bootloader_install, # noqa + } + self.assertEqual(expected_config, data['config']) + self.assertIsNotNone(data['config']['agent_token']) +diff --git a/releasenotes/notes/disable-installing-bootloaders-by-default-4a3c69777069587c.yaml b/releasenotes/notes/disable-installing-bootloaders-by-default-4a3c69777069587c.yaml +new file mode 100644 +index 0000000..c305ef0 +--- /dev/null ++++ b/releasenotes/notes/disable-installing-bootloaders-by-default-4a3c69777069587c.yaml +@@ -0,0 +1,11 @@ ++--- ++security: ++ - | ++ Disable installation of bootloaders (via grub-install) by IPA by default ++ in order to improve security posture by adding a new agent configuration ++ option `enable_bios_bootloader_install` which defaults to `True` for this ++ branch of Ironic. ++ ++ Operators who have a stricter security posture or do not need this ++ functionality can disable installation of bootloaders by setting ++ `enable_bios_bootloader_install` to `False`. Addresses CVE-2026-43003. diff -Nru ironic-29.0.5/debian/patches/CVE-2026-44918-Prevent_rehoming_resources_to_nodes_with_different_owner.patch ironic-29.0.5/debian/patches/CVE-2026-44918-Prevent_rehoming_resources_to_nodes_with_different_owner.patch --- ironic-29.0.5/debian/patches/CVE-2026-44918-Prevent_rehoming_resources_to_nodes_with_different_owner.patch 1970-01-01 00:00:00.000000000 +0000 +++ ironic-29.0.5/debian/patches/CVE-2026-44918-Prevent_rehoming_resources_to_nodes_with_different_owner.patch 2026-06-16 10:46:11.000000000 +0000 @@ -0,0 +1,737 @@ +Author: Dmitry Tantsur +Date: Mon, 4 May 2026 17:14:07 +0200 +Description: CVE-2026-44918: Prevent rehoming resources to nodes with different owner + Ironic has a few similar patterns where only the source node is verified + during a create or update operation: + 1. A node can be created with a parent from a different project + 2. Node can be updated to have a parent from a different project + 3. Ports, port groups, volume targets and volume connectors can be + patched to be owned by a node from a different project. + . + This patch addresses all these cases and introduces a single helper to + cover all of them. + . + Also makes sure that baremetal:node:get is checked even if the parent + node ID is a UUID (it was missing before). +Bug: https://launchpad.net/bugs/2150450 +Bug-Debian: https://bugs.debian.org/1141716 +Change-Id: Iaa512f380926a9fc032eefea5bcdcbc5040a4739 +Signed-off-by: Julia Kreger +Origin: pre-OSSA mailing list +Last-Update: 2026-06-26 + +diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py +index 65224abf2..6dee9ca0b 100644 +--- a/ironic/api/controllers/v1/node.py ++++ b/ironic/api/controllers/v1/node.py +@@ -2957,11 +2957,9 @@ class NodesController(rest.RestController): + chassis = _replace_chassis_uuid_with_id(node) + chassis_uuid = chassis and chassis.uuid or None + +- if ('parent_node' in node +- and not uuidutils.is_uuid_like(node['parent_node'])): +- +- parent_node = api_utils.check_node_policy_and_retrieve( +- 'baremetal:node:get', node['parent_node']) ++ if node.get('parent_node'): ++ parent_node = api_utils.authorize_node_link( ++ node, node['parent_node'], 'parent_node') + node['parent_node'] = parent_node.uuid + + new_node = objects.Node(context, **node) +@@ -3027,20 +3025,6 @@ class NodesController(rest.RestController): + + for network_data in network_data_fields: + validate_network_data(network_data) +- parent_node = api_utils.get_patch_values(patch, '/parent_node') +- if parent_node: +- # At this point, if there *is* a parent_node, there is a value +- # in the patch value list. +- try: +- # Verify we can see the parent node +- req_parent_node = api_utils.check_node_policy_and_retrieve( +- 'baremetal:node:get', parent_node[0]) +- # If we can't see the node, an exception gets raised. +- except Exception: +- msg = _("Unable to apply the requested parent_node. " +- "Requested value was invalid.") +- raise exception.Invalid(msg) +- corrected_values['parent_node'] = req_parent_node.uuid + return corrected_values + + def _authorize_patch_and_get_node(self, node_ident, patch): +@@ -3129,6 +3113,10 @@ class NodesController(rest.RestController): + context = api.request.context + rpc_node = self._authorize_patch_and_get_node(node_ident, patch) + ++ if parent_node := api_utils.authorize_node_link_patch( ++ rpc_node, patch, 'parent_node'): ++ corrected_values['parent_node'] = parent_node.uuid ++ + remove_inst_uuid_patch = [{'op': 'remove', 'path': '/instance_uuid'}] + if rpc_node.maintenance and patch == remove_inst_uuid_patch: + LOG.debug('Removing instance uuid %(instance)s from node %(node)s', +diff --git a/ironic/api/controllers/v1/port.py b/ironic/api/controllers/v1/port.py +index f70c24093..80889c12d 100644 +--- a/ironic/api/controllers/v1/port.py ++++ b/ironic/api/controllers/v1/port.py +@@ -689,12 +689,9 @@ class PortsController(rest.RestController): + 'baremetal:port:update', port_ident) + + port_dict = rpc_port.as_dict() +- # NOTE(lucasagomes): +- # 1) Remove node_id because it's an internal value and ++ # NOTE(lucasagomes): Remove node_id because it's an internal value and + # not present in the API object +- # 2) Add node_uuid + port_dict.pop('node_id', None) +- port_dict['node_uuid'] = rpc_node.uuid + # NOTE(vsaienko): + # 1) Remove portgroup_id because it's an internal value and + # not present in the API object +@@ -705,7 +702,10 @@ class PortsController(rest.RestController): + context, port_dict.pop('portgroup_id')) + port_dict['portgroup_uuid'] = portgroup and portgroup.uuid or None + ++ rpc_node = api_utils.authorize_node_link_patch( ++ rpc_node, patch, 'node_uuid') + port_dict = api_utils.apply_jsonpatch(port_dict, patch) ++ port_dict['node_uuid'] = rpc_node.uuid + + try: + if api_utils.is_path_updated(patch, '/portgroup_uuid'): +@@ -720,16 +720,6 @@ class PortsController(rest.RestController): + e.code = http_client.BAD_REQUEST # BadRequest + raise + +- try: +- if port_dict['node_uuid'] != rpc_node.uuid: +- rpc_node = objects.Node.get( +- api.request.context, port_dict['node_uuid']) +- except exception.NodeNotFound as e: +- # Change error code because 404 (NotFound) is inappropriate +- # response for a PATCH request to change a Port +- e.code = http_client.BAD_REQUEST # BadRequest +- raise +- + api_utils.patched_validate_with_schema( + port_dict, PORT_PATCH_SCHEMA, PORT_PATCH_VALIDATOR) + +diff --git a/ironic/api/controllers/v1/portgroup.py b/ironic/api/controllers/v1/portgroup.py +index ec1035e11..9c30a9000 100644 +--- a/ironic/api/controllers/v1/portgroup.py ++++ b/ironic/api/controllers/v1/portgroup.py +@@ -472,29 +472,20 @@ class PortgroupsController(pecan.rest.RestController): + + portgroup_dict = rpc_portgroup.as_dict() + +- # NOTE: +- # 1) Remove node_id because it's an internal value and ++ # NOTE: Remove node_id because it's an internal value and + # not present in the API object +- # 2) Add node_uuid + portgroup_dict.pop('node_id') +- portgroup_dict['node_uuid'] = rpc_node.uuid ++ ++ rpc_node = api_utils.authorize_node_link_patch( ++ rpc_node, patch, 'node_uuid') ++ + portgroup_dict = api_utils.apply_jsonpatch(portgroup_dict, patch) ++ portgroup_dict['node_uuid'] = rpc_node.uuid + + if 'mode' not in portgroup_dict: + msg = _("'mode' is a mandatory attribute and can not be removed") + raise exception.ClientSideError(msg) + +- try: +- if portgroup_dict['node_uuid'] != rpc_node.uuid: +- rpc_node = objects.Node.get(api.request.context, +- portgroup_dict['node_uuid']) +- +- except exception.NodeNotFound as e: +- # Change error code because 404 (NotFound) is inappropriate +- # response for a POST request to patch a Portgroup +- e.code = http_client.BAD_REQUEST # BadRequest +- raise +- + api_utils.patched_validate_with_schema( + portgroup_dict, PORTGROUP_PATCH_SCHEMA, PORTGROUP_PATCH_VALIDATOR) + +diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py +index 244940ab4..629cdcecb 100644 +--- a/ironic/api/controllers/v1/utils.py ++++ b/ironic/api/controllers/v1/utils.py +@@ -26,6 +26,7 @@ import jsonschema + from jsonschema import exceptions as json_schema_exc + import os_traits + from oslo_config import cfg ++from oslo_log import log + from oslo_policy import policy as oslo_policy + from oslo_utils import uuidutils + from pecan import rest +@@ -47,6 +48,8 @@ from ironic.objects import fields as ofields + + CONF = cfg.CONF + ++LOG = log.getLogger(__name__) ++ + + _JSONPATCH_EXCEPTIONS = (jsonpatch.JsonPatchConflict, + jsonpatch.JsonPatchException, +@@ -325,28 +328,6 @@ def replace_node_uuid_with_id(to_dict): + return node + + +-def replace_node_id_with_uuid(to_dict): +- """Replace ``node_id`` dict value with ``node_uuid`` +- +- ``node_uuid`` is found by fetching the node by id lookup. +- +- :param to_dict: Dict to set ``node_uuid`` value on +- :returns: The node object from the lookup +- :raises: NodeNotFound with status_code set to 400 BAD_REQUEST +- when node is not found. +- """ +- try: +- node = objects.Node.get_by_id(api.request.context, +- to_dict.pop('node_id')) +- to_dict['node_uuid'] = node.uuid +- except exception.NodeNotFound as e: +- # Change error code because 404 (NotFound) is inappropriate +- # response for requests acting on non-nodes +- e.code = http_client.BAD_REQUEST # BadRequest +- raise +- return node +- +- + def patch_update_changed_fields(from_dict, rpc_object, fields, + schema, id_map=None): + """Update rpc object based on changed fields in a dict. +@@ -1574,6 +1555,57 @@ def check_policy_true(policy_name): + return policy.check_policy(policy_name, cdict, api.request.context) + + ++def authorize_node_link(orig_node, new_uuid, field_name): ++ """Authorize creating or changing a link to the node on a resource. ++ ++ :param orig_node: Node that currently owns the resource. ++ :param new_uuid: UUID of the new node. ++ :param field_name: Human-readable field name for logging and error message. ++ :raises: Invalid on access error ++ :returns: The new Node object ++ """ ++ msg = _("Unable to apply the requested %s '%s'. " ++ "Requested value was invalid.") ++ ++ try: ++ new_node = check_node_policy_and_retrieve( ++ 'baremetal:node:get', new_uuid) ++ except Exception as exc: ++ LOG.debug("Rejecting %s %s: %s", field_name, new_uuid, exc) ++ # Important: do not disclose if the node exists ++ raise exception.Invalid(msg % (field_name, new_uuid)) ++ ++ if isinstance(orig_node, objects.Node): ++ orig_node = orig_node.as_dict() # adjust for different callers ++ ++ if orig_node.get('owner') != new_node.owner: ++ LOG.warning("Project mismatch on setting or changing %(field)s: " ++ "current owner '%(orig)s', new %(field)s owner '%(new)s'", ++ {'orig': orig_node.get('owner'), 'new': new_node.owner, ++ 'field': field_name}) ++ # Important: same error message as when the node does not exist ++ raise exception.Invalid(msg % (field_name, new_uuid)) ++ ++ return new_node ++ ++ ++def authorize_node_link_patch(orig_node, patch, field_name): ++ """Authorize changing a link to the node on a resource. ++ ++ :param orig_node: Node that currently owns the resource. ++ :param patch: JSON patch being applied. ++ :param field_name: Field names that contains the link. ++ :raises: Invalid on access error ++ :returns: The new Node object or the old one if not changed ++ """ ++ new_node_id = get_patch_values(patch, f'/{field_name}') ++ if new_node_id: ++ return authorize_node_link( ++ orig_node, new_node_id[0], field_name) ++ ++ return orig_node ++ ++ + def duplicate_steps(name, value): + """Argument validator to check template for duplicate steps""" + # TODO(mgoddard): Determine the consequences of allowing duplicate +diff --git a/ironic/api/controllers/v1/volume_connector.py b/ironic/api/controllers/v1/volume_connector.py +index a94d4282b..e7683d063 100644 +--- a/ironic/api/controllers/v1/volume_connector.py ++++ b/ironic/api/controllers/v1/volume_connector.py +@@ -332,23 +332,16 @@ class VolumeConnectorsController(rest.RestController): + raise exception.InvalidUUID(message=message) + + connector_dict = rpc_connector.as_dict() +- # NOTE(smoriya): +- # 1) Remove node_id because it's an internal value and ++ # NOTE(smoriya): Remove node_id because it's an internal value and + # not present in the API object +- # 2) Add node_uuid +- rpc_node = api_utils.replace_node_id_with_uuid(connector_dict) ++ connector_dict.pop('node_id', None) ++ # NOTE(dtantsur): Patch won't apply if the field does not exist. ++ connector_dict['node_uuid'] = None + ++ rpc_node = api_utils.authorize_node_link_patch( ++ rpc_node, patch, 'node_uuid') + connector_dict = api_utils.apply_jsonpatch(connector_dict, patch) +- +- try: +- if connector_dict['node_uuid'] != rpc_node.uuid: +- rpc_node = objects.Node.get( +- api.request.context, connector_dict['node_uuid']) +- except exception.NodeNotFound as e: +- # Change error code because 404 (NotFound) is inappropriate +- # response for a PATCH request to change a Port +- e.code = http_client.BAD_REQUEST # BadRequest +- raise ++ connector_dict['node_uuid'] = rpc_node.uuid + + api_utils.patched_validate_with_schema( + connector_dict, CONNECTOR_SCHEMA, CONNECTOR_VALIDATOR) +diff --git a/ironic/api/controllers/v1/volume_target.py b/ironic/api/controllers/v1/volume_target.py +index e7f477c85..7d50649c6 100644 +--- a/ironic/api/controllers/v1/volume_target.py ++++ b/ironic/api/controllers/v1/volume_target.py +@@ -353,9 +353,8 @@ class VolumeTargetsController(rest.RestController): + """ + context = api.request.context + +- api_utils.check_volume_policy_and_retrieve('baremetal:volume:update', +- target_uuid, +- target=True) ++ rpc_target, rpc_node = api_utils.check_volume_policy_and_retrieve( ++ 'baremetal:volume:update', target_uuid, target=True) + + if self.parent_node_ident: + raise exception.OperationNotPermitted() +@@ -369,29 +368,17 @@ class VolumeTargetsController(rest.RestController): + "%(uuid)s.") % {'uuid': str(value)} + raise exception.InvalidUUID(message=message) + +- rpc_target = objects.VolumeTarget.get_by_uuid(context, target_uuid) + target_dict = rpc_target.as_dict() +- # NOTE(smoriya): +- # 1) Remove node_id because it's an internal value and ++ # NOTE(smoriya): Remove node_id because it's an internal value and + # not present in the API object +- # 2) Add node_uuid +- rpc_node = api_utils.replace_node_id_with_uuid(target_dict) ++ target_dict.pop('node_id', None) ++ # NOTE(dtantsur): Patch won't apply if the field does not exist. ++ target_dict['node_uuid'] = None + ++ rpc_node = api_utils.authorize_node_link_patch( ++ rpc_node, patch, 'node_uuid') + target_dict = api_utils.apply_jsonpatch(target_dict, patch) +- +- try: +- if target_dict['node_uuid'] != rpc_node.uuid: +- +- # TODO(TheJulia): I guess the intention is to +- # permit the mapping to be changed +- # should we even allow this at all? +- rpc_node = objects.Node.get( +- api.request.context, target_dict['node_uuid']) +- except exception.NodeNotFound as e: +- # Change error code because 404 (NotFound) is inappropriate +- # response for a PATCH request to change a volume target +- e.code = http_client.BAD_REQUEST # BadRequest +- raise ++ target_dict['node_uuid'] = rpc_node.uuid + + api_utils.patched_validate_with_schema( + target_dict, TARGET_SCHEMA, TARGET_VALIDATOR) +diff --git a/ironic/cmd/status.py b/ironic/cmd/status.py +index 63cfcc746..dc735cda8 100644 +--- a/ironic/cmd/status.py ++++ b/ironic/cmd/status.py +@@ -20,6 +20,7 @@ from oslo_db.sqlalchemy import utils + from oslo_upgradecheck import common_checks + from oslo_upgradecheck import upgradecheck + import sqlalchemy ++from sqlalchemy import orm + + from ironic.cmd import dbsync + from ironic.common import driver_factory +@@ -27,6 +28,7 @@ from ironic.common.i18n import _ + from ironic.common import policy # noqa importing to load policy config. + import ironic.conf + from ironic.db import api as db_api ++from ironic.db.sqlalchemy import models + + CONF = ironic.conf.CONF + +@@ -181,6 +183,38 @@ class Checks(upgradecheck.UpgradeCommands): + else: + return upgradecheck.Result(upgradecheck.Code.SUCCESS) + ++ def _check_parent_child_owners(self): ++ engine = enginefacade.reader.get_engine() ++ parent_node = orm.aliased(models.Node) ++ broken = [] ++ with engine.connect() as conn, conn.begin(): ++ # NOTE(dtantsur): since NULL handling in SQL is insane, we need to ++ # handle 3 possible cases separately. ++ conditions = ( ++ (parent_node.owner != models.Node.owner) ++ | (models.Node.owner.is_not(None) ++ & parent_node.owner.is_(None)) ++ | (models.Node.owner.is_(None) ++ & parent_node.owner.is_not(None)) ++ ) ++ query = (sqlalchemy.select(models.Node.uuid, ++ models.Node.owner, ++ parent_node.uuid, ++ parent_node.owner) ++ .join(parent_node, ++ models.Node.parent_node == parent_node.uuid) ++ .where(conditions)) ++ res = conn.execute(query) ++ for child_uuid, child_owner, parent_uuid, parent_owner in res: ++ broken.append(f"- {child_uuid}: owner {child_owner}, " ++ f"parent {parent_uuid} owner {parent_owner}") ++ if broken: ++ msg = ("Some nodes have an owner mismatch with parents:\n" ++ + "\n".join(broken)) ++ return upgradecheck.Result(upgradecheck.Code.WARNING, details=msg) ++ else: ++ return upgradecheck.Result(upgradecheck.Code.SUCCESS) ++ + # A tuple of check tuples of (, ). + # The name of the check will be used in the output of this command. + # The check function takes no arguments and returns an +@@ -199,6 +233,9 @@ class Checks(upgradecheck.UpgradeCommands): + (common_checks.check_policy_json, {'conf': CONF})), + (_('Hardware Types and Interfaces Check'), + _check_hardware_types_interfaces), ++ # Cases of CVE-2026-44918 ++ (_('Mismatch between Child and Parent Owners'), ++ _check_parent_child_owners), + ) + + +diff --git a/ironic/tests/unit/api/controllers/v1/test_utils.py b/ironic/tests/unit/api/controllers/v1/test_utils.py +index 1f1279874..4bf1414e6 100644 +--- a/ironic/tests/unit/api/controllers/v1/test_utils.py ++++ b/ironic/tests/unit/api/controllers/v1/test_utils.py +@@ -931,24 +931,6 @@ class TestNodeIdent(base.TestCase): + utils.replace_node_uuid_with_id, to_dict) + self.assertEqual(400, e.code) + +- @mock.patch.object(objects.Node, 'get_by_id', autospec=True) +- def test_replace_node_id_with_uuid(self, mock_gbi, mock_pr): +- node = obj_utils.get_test_node(self.context, uuid=self.valid_uuid) +- mock_gbi.return_value = node +- to_dict = {'node_id': 1} +- +- self.assertEqual(node, utils.replace_node_id_with_uuid(to_dict)) +- self.assertEqual({'node_uuid': self.valid_uuid}, to_dict) +- +- @mock.patch.object(objects.Node, 'get_by_id', autospec=True) +- def test_replace_node_id_with_uuid_not_found(self, mock_gbi, mock_pr): +- to_dict = {'node_id': 1} +- mock_gbi.side_effect = exception.NodeNotFound(node=1) +- +- e = self.assertRaises(exception.NodeNotFound, +- utils.replace_node_id_with_uuid, to_dict) +- self.assertEqual(400, e.code) +- + + class TestVendorPassthru(base.TestCase): + +diff --git a/ironic/tests/unit/api/test_rbac_project_scoped.yaml b/ironic/tests/unit/api/test_rbac_project_scoped.yaml +index 32782b1cf..6f5c4606a 100644 +--- a/ironic/tests/unit/api/test_rbac_project_scoped.yaml ++++ b/ironic/tests/unit/api/test_rbac_project_scoped.yaml +@@ -2468,6 +2468,30 @@ third_party_admin_cannot_modify_portgroup: + body: *portgroup_patch_body + assert_status: 404 + ++owner_admin_cannot_rehome_portgroup: ++ path: '/v1/portgroups/{owner_portgroup_ident}' ++ method: patch ++ headers: *owner_admin_headers ++ body: &other_node_portgroup_patch_body ++ - op: replace ++ path: /node_uuid ++ value: 573208e5-cd41-4e26-8f06-ef44022b3793 ++ assert_status: 400 ++ ++owner_manager_cannot_rehome_portgroup: ++ path: '/v1/portgroups/{owner_portgroup_ident}' ++ method: patch ++ headers: *owner_manager_headers ++ body: *other_node_portgroup_patch_body ++ assert_status: 400 ++ ++owner_service_cannot_rehome_portgroup: ++ path: '/v1/portgroups/{owner_portgroup_ident}' ++ method: patch ++ headers: *service_headers_owner_project ++ body: *other_node_portgroup_patch_body ++ assert_status: 400 ++ + owner_admin_can_delete_portgroup: + path: '/v1/portgroups/{owner_portgroup_ident}' + method: delete +@@ -2768,6 +2792,30 @@ owner_admin_can_delete_port: + headers: *owner_admin_headers + assert_status: 503 + ++owner_admin_cannot_rehome_port: ++ path: '/v1/ports/{owner_port_ident}' ++ method: patch ++ headers: *owner_admin_headers ++ body: &other_node_port_patch_body ++ - op: replace ++ path: /node_uuid ++ value: 573208e5-cd41-4e26-8f06-ef44022b3793 ++ assert_status: 400 ++ ++owner_manager_cannot_rehome_port: ++ path: '/v1/ports/{owner_port_ident}' ++ method: patch ++ headers: *owner_manager_headers ++ body: *other_node_port_patch_body ++ assert_status: 400 ++ ++owner_service_cannot_rehome_port: ++ path: '/v1/ports/{owner_port_ident}' ++ method: patch ++ headers: *service_headers_owner_project ++ body: *other_node_port_patch_body ++ assert_status: 400 ++ + owner_manager_can_delete_port: + path: '/v1/ports/{owner_port_ident}' + method: delete +@@ -3044,6 +3092,30 @@ third_party_admin_cannot_patch_volume_connectors: + body: *connector_patch_body + assert_status: 404 + ++owner_admin_cannot_rehome_volume_connector: ++ path: '/v1/volume/connectors/{volume_connector_ident}' ++ method: patch ++ headers: *owner_admin_headers ++ body: &other_node_connector_patch_body ++ - op: replace ++ path: /node_uuid ++ value: 573208e5-cd41-4e26-8f06-ef44022b3793 ++ assert_status: 400 ++ ++owner_manager_cannot_rehome_volume_connector: ++ path: '/v1/volume/connectors/{volume_connector_ident}' ++ method: patch ++ headers: *owner_manager_headers ++ body: *other_node_connector_patch_body ++ assert_status: 400 ++ ++owner_service_cannot_rehome_volume_connector: ++ path: '/v1/volume/connectors/{volume_connector_ident}' ++ method: patch ++ headers: *service_headers_owner_project ++ body: *other_node_connector_patch_body ++ assert_status: 400 ++ + owner_admin_can_delete_volume_connectors: + path: '/v1/volume/connectors/{volume_connector_ident}' + method: delete +@@ -3245,6 +3317,30 @@ service_cannot_patch_volume_target: + headers: *service_headers + assert_status: 404 + ++owner_admin_cannot_rehome_volume_target: ++ path: '/v1/volume/targets/{volume_target_ident}' ++ method: patch ++ headers: *owner_admin_headers ++ body: &other_node_target_patch_body ++ - op: replace ++ path: /node_uuid ++ value: 573208e5-cd41-4e26-8f06-ef44022b3793 ++ assert_status: 400 ++ ++owner_manager_cannot_rehome_volume_target: ++ path: '/v1/volume/targets/{volume_target_ident}' ++ method: patch ++ headers: *owner_manager_headers ++ body: *other_node_target_patch_body ++ assert_status: 400 ++ ++owner_service_cannot_rehome_volume_target: ++ path: '/v1/volume/targets/{volume_target_ident}' ++ method: patch ++ headers: *service_headers_owner_project ++ body: *other_node_target_patch_body ++ assert_status: 400 ++ + owner_admin_can_delete_volume_target: + path: '/v1/volume/targets/{volume_target_ident}' + method: delete +@@ -3962,6 +4058,35 @@ shard_patch_set_node_shard_disallowed: + value: 'TestShard' + assert_status: 403 + ++# Create node with parent_node field ++ ++admin_can_create_node_with_parent: ++ path: '/v1/nodes' ++ method: post ++ headers: *owner_admin_headers ++ body: ++ driver: fake ++ parent_node: *owned_node_ident ++ assert_status: 503 ++ ++admin_cannot_create_node_with_non_existing_parent: ++ path: '/v1/nodes' ++ method: post ++ headers: *owner_admin_headers ++ body: ++ driver: fake ++ parent_node: 'f11853c7-fa9c-4db3-a477-c9d8e0dbbf13' ++ assert_status: 400 ++ ++admin_cannot_create_node_with_parent_they_cannot_see: ++ path: '/v1/nodes' ++ method: post ++ headers: *owner_admin_headers ++ body: ++ driver: fake ++ parent_node: '{node_ident}' ++ assert_status: 400 ++ + # Update node parent_node field - baremetal:node:update:parent_node + + parent_node_patch_by_admin: +@@ -3996,8 +4121,7 @@ parent_node_patch_by_manager: + assert_status: 403 + + parent_node_patch_by_cannot_see_node: +- # This node cannot be seen, and also just doesn't exist. +- # Just to verify we return a 400 on a node we can change. ++ # Avoid exposing whether the node exists if not authorized + path: '/v1/nodes/{lessee_node_ident}' + method: patch + headers: *owner_admin_headers +@@ -4005,7 +4129,7 @@ parent_node_patch_by_cannot_see_node: + - op: replace + path: /parent_node + value: 'f11853c7-fa9c-4db3-a477-c9d8e0dbbf13' +- assert_status: 400 ++ assert_status: 403 + + parent_node_children_can_get_list_of_children: + path: '/v1/nodes/{owner_node_ident}/children' +diff --git a/ironic/tests/unit/cmd/test_status.py b/ironic/tests/unit/cmd/test_status.py +index 42ebe339c..700e6f778 100644 +--- a/ironic/tests/unit/cmd/test_status.py ++++ b/ironic/tests/unit/cmd/test_status.py +@@ -16,6 +16,7 @@ from unittest import mock + + from oslo_db import sqlalchemy + from oslo_upgradecheck.upgradecheck import Code ++from oslo_utils import uuidutils + from sqlalchemy.engine import url as sa_url + + from ironic.cmd import dbsync +@@ -146,3 +147,58 @@ class TestUpgradeChecks(db_base.DbTestCase): + 'table engine to utilize InnoDB, and reload the ' + 'allocations table to utilize the InnoDB engine.') + self.assertEqual(expected_msg, check_result.details) ++ ++ ++class TestUpgradeChecksOwnerMismatch(db_base.DbTestCase): ++ ++ def setUp(self): ++ super().setUp() ++ self.cmd = status.Checks() ++ ++ def test_correct_nodes(self): ++ parents = [ ++ # Parent without an owner ++ {'uuid': uuidutils.generate_uuid()}, ++ # Parent with an owner ++ {'uuid': uuidutils.generate_uuid(), ++ 'owner': 'abcd'}, ++ ] ++ children = [ ++ {'parent_node': parents[0]['uuid']}, ++ {'parent_node': parents[1]['uuid'], ++ 'owner': parents[1]['owner']}, ++ ] ++ for node in parents + children: ++ self.dbapi.create_node(node) ++ ++ result = self.cmd._check_parent_child_owners() ++ self.assertEqual(Code.SUCCESS, result.code) ++ ++ def test_mismatch(self): ++ parents = [ ++ # Parent without an owner ++ {'uuid': uuidutils.generate_uuid()}, ++ # Parent with an owner ++ {'uuid': uuidutils.generate_uuid(), ++ 'owner': 'abcd'}, ++ ] ++ children = [ ++ # Owned child of a parent without owner ++ {'uuid': uuidutils.generate_uuid(), ++ 'parent_node': parents[0]['uuid'], ++ 'owner': 'abcd'}, ++ # Child of an owned node without owner ++ {'uuid': uuidutils.generate_uuid(), ++ 'parent_node': parents[1]['uuid']}, ++ # Mismatched owners ++ {'uuid': uuidutils.generate_uuid(), ++ 'parent_node': parents[1]['uuid'], ++ 'owner': parents[1]['owner'] + 'bad'}, ++ ] ++ for node in parents + children: ++ self.dbapi.create_node(node) ++ ++ result = self.cmd._check_parent_child_owners() ++ self.assertEqual(Code.WARNING, result.code) ++ for child in children: ++ self.assertIn(child['uuid'], result.details) +diff --git a/releasenotes/notes/2150450-owners-b4019d5fa63a8bc1.yaml b/releasenotes/notes/2150450-owners-b4019d5fa63a8bc1.yaml +new file mode 100644 +index 000000000..9d7e04e0d +--- /dev/null ++++ b/releasenotes/notes/2150450-owners-b4019d5fa63a8bc1.yaml +@@ -0,0 +1,21 @@ ++--- ++security: ++ - | ++ Addresses a potential security issues where an admin of a project could ++ create nodes with ``parent_node`` set to a node from a different project. ++ - | ++ Prevents changing the ``node_uuid`` of ports, port groups, volume targets, ++ and volume connectors to point to a node with a different owner from the ++ initial node. In some cases this is not normally permitted due to the ++ database model, but additional access checking was added across these ++ similar resources for consistency in the event the Ironic project fixes ++ `12150252 `_. ++issues: ++ - | ++ System operators should note that changing the owner field on a node does ++ not affect its child or parent nodes, potentially resulting in these nodes ++ being in different projects. ++upgrade: ++ - | ++ Adds an upgrade check that issues a warning when any node has a different ++ owner from its parent node. +-- +2.47.3 + diff -Nru ironic-29.0.5/debian/patches/CVE-2026-46447_erata1_Fix_kernel_parameter_parsing_for_quoted_values_and_whitespace.patch ironic-29.0.5/debian/patches/CVE-2026-46447_erata1_Fix_kernel_parameter_parsing_for_quoted_values_and_whitespace.patch --- ironic-29.0.5/debian/patches/CVE-2026-46447_erata1_Fix_kernel_parameter_parsing_for_quoted_values_and_whitespace.patch 1970-01-01 00:00:00.000000000 +0000 +++ ironic-29.0.5/debian/patches/CVE-2026-46447_erata1_Fix_kernel_parameter_parsing_for_quoted_values_and_whitespace.patch 2026-06-16 10:46:11.000000000 +0000 @@ -0,0 +1,145 @@ +From 7c00fdb8822a066fe7dd22d0360a1615486eb807 Mon Sep 17 00:00:00 2001 +From: Riccardo Pittau +Date: Mon, 08 Jun 2026 09:15:33 +0200 +Subject: [PATCH] Fix kernel parameter parsing for quoted values and whitespace + +The kernel parameter validator introduced in commit c6c91d649 has +three bugs in the KernelParameterTransformer class: + +1. The quoted_value() method tries to access items[0].value but + items[0] is already a plain string (returned by value_with_spaces + transformer), not a lark Token. This causes all quoted parameter + values (e.g. sshkey="ssh-rsa ... user@host") to fail with + "'str' object has no attribute 'value'". + +2. KernelParameter.__str__() uses self.value.value directly, bypassing + ParameterValue.__str__() which wraps space-containing values in + quotes. This breaks roundtrip rendering of quoted values. + +3. parse() does not strip surrounding whitespace from the input, + causing failures when config values have trailing spaces or + newlines from template expansion or oslo.config. + +Assisted-By: Claude Opus 4.6 +Change-Id: I9a398d557c3f87c070ffa10e317f0df613b98b6b +Signed-off-by: Riccardo Pittau +(cherry picked from commit a58fe7d548e3b874b7538d6272b2a73f3a0d4b94) +(cherry picked from commit 87807ea7ce1424293ae6443de61fd4847a34619f) +(cherry picked from commit 1908af6cd4ef02f9e33ba4ed6e8ffff8e0afbf79) +--- + +diff --git a/ironic/common/kernel_parameters.py b/ironic/common/kernel_parameters.py +index ca0cc92..2af70d4 100644 +--- a/ironic/common/kernel_parameters.py ++++ b/ironic/common/kernel_parameters.py +@@ -66,7 +66,7 @@ + + def __str__(self): + if len(self.value.value) > 0: +- return f"{self.key.key}={self.value.value}" ++ return f"{self.key}={self.value}" + return self.key.key + + +@@ -100,7 +100,7 @@ + def parse(cls, command_line: str): + try: + cmd_line, init_args = \ +- _divide_command_line_by_init_args(command_line) ++ _divide_command_line_by_init_args(command_line.strip()) + tree = KernelParameterParser.parse(cmd_line) + kcl = KernelParameterTransformer().transform(tree) + return KernelCommandLine(kcl.parameters, init_args) +@@ -143,8 +143,7 @@ + return ParameterValue(items[0]) + + def quoted_value(self, items): +- # Strip " characters from literal. +- return items[0].value[1:-1] ++ return items[0] + + def bare_value(self, items): + return items[0].value +diff --git a/ironic/tests/unit/common/test_kernel_parameters.py b/ironic/tests/unit/common/test_kernel_parameters.py +index f64f46e..83e1f1d 100644 +--- a/ironic/tests/unit/common/test_kernel_parameters.py ++++ b/ironic/tests/unit/common/test_kernel_parameters.py +@@ -205,6 +205,54 @@ + )], + }, "some init args") + ), ++ annotate( ++ "Quoted value with spaces (sshkey pattern)", ++ 'sshkey="ssh-rsa AAAAB3NzaC1yc2E= root@host"', ++ kp.KernelCommandLine({ ++ 'sshkey': [kp.KernelParameter( ++ kp.ParameterKey('sshkey'), ++ kp.ParameterValue( ++ 'ssh-rsa AAAAB3NzaC1yc2E= root@host'), ++ )], ++ }, "") ++ ), ++ annotate( ++ "Quoted value with SSH key and following parameters", ++ ('nofb nomodeset vga=normal ipa-insecure=1 ' ++ 'sshkey="ssh-rsa AAAAB3NzaC+/C1yc2E= root@host.example.com" ' ++ 'rd.net.timeout.carrier=30 ip=dhcp'), ++ kp.KernelCommandLine({ ++ 'nofb': [kp.KernelParameter( ++ kp.ParameterKey('nofb'), ++ kp.ParameterValue(''), ++ )], ++ 'nomodeset': [kp.KernelParameter( ++ kp.ParameterKey('nomodeset'), ++ kp.ParameterValue(''), ++ )], ++ 'vga': [kp.KernelParameter( ++ kp.ParameterKey('vga'), ++ kp.ParameterValue('normal'), ++ )], ++ 'ipa-insecure': [kp.KernelParameter( ++ kp.ParameterKey('ipa-insecure'), ++ kp.ParameterValue('1'), ++ )], ++ 'sshkey': [kp.KernelParameter( ++ kp.ParameterKey('sshkey'), ++ kp.ParameterValue( ++ 'ssh-rsa AAAAB3NzaC+/C1yc2E= root@host.example.com'), ++ )], ++ 'rd.net.timeout.carrier': [kp.KernelParameter( ++ kp.ParameterKey('rd.net.timeout.carrier'), ++ kp.ParameterValue('30'), ++ )], ++ 'ip': [kp.KernelParameter( ++ kp.ParameterKey('ip'), ++ kp.ParameterValue('dhcp'), ++ )], ++ }, "") ++ ), + ) + @unpack + def test_kernel_command_line_parsing( +@@ -228,3 +276,23 @@ + self.assertRaises(InvalidParameterValue, + kp.KernelCommandLine.parse, + command_line) ++ ++ @data( ++ annotate( ++ "trailing space", ++ "quiet ro ", ++ ), ++ annotate( ++ "leading space", ++ " quiet ro", ++ ), ++ annotate( ++ "leading and trailing spaces", ++ " quiet ro ", ++ ), ++ ) ++ @unpack ++ def test_parse_strips_surrounding_whitespace(self, command_line: str): ++ result = kp.KernelCommandLine.parse(command_line) ++ self.assertEqual('quiet', list(result.parameters.keys())[0]) ++ self.assertEqual('ro', list(result.parameters.keys())[1]) diff -Nru ironic-29.0.5/debian/patches/CVE-2026-54421_Fix_sensitive_properties_returned_on_volume_targets.patch ironic-29.0.5/debian/patches/CVE-2026-54421_Fix_sensitive_properties_returned_on_volume_targets.patch --- ironic-29.0.5/debian/patches/CVE-2026-54421_Fix_sensitive_properties_returned_on_volume_targets.patch 1970-01-01 00:00:00.000000000 +0000 +++ ironic-29.0.5/debian/patches/CVE-2026-54421_Fix_sensitive_properties_returned_on_volume_targets.patch 2026-06-16 10:46:11.000000000 +0000 @@ -0,0 +1,298 @@ +Author: Saad Zia +Date: Thu, 28 May 2026 10:51:32 +0300 +Description: [PATCH] Security: Fix sensitive properties returned on volume targets + Ensure that volume target properties are properly redacted when the + user lacks the 'baremetal:volume:view_target_properties' policy in + patch() similar to get_one(). + . + Additionally, Ironic Core Security determined that the RBAC reliant + control over who can see properties was insufficient, especially in + standalone use cases, and such the field handling is now consistent + with other sensitive field usage. + . +Assisted-By: Claude Opus 4.6 +Related-Bug: 2154564 +Related-Bug: 2155049 +Bug-Debian: https://bugs.debian.org/1140012 +Change-Id: I8366088731aa34da0e99941924c222ffed455c8b +Signed-off-by: Saad Zia +Signed-off-by: Julia Kreger +Origin: https://review.opendev.org/c/openstack/ironic/+/992326 +Last-Update: 2026-06-16 + +diff --git a/ironic/api/controllers/v1/volume_target.py b/ironic/api/controllers/v1/volume_target.py +index 21ebcad..e7f477c 100644 +--- a/ironic/api/controllers/v1/volume_target.py ++++ b/ironic/api/controllers/v1/volume_target.py +@@ -14,6 +14,7 @@ + + from http import client as http_client + ++from oslo_utils import strutils + from oslo_utils import uuidutils + from pecan import rest + +@@ -90,11 +91,18 @@ + if not sanitize: + return target + +- api_utils.sanitize_dict(target, fields) ++ target_sanitize(target, fields) + + return target + + ++def target_sanitize(target, fields=None): ++ api_utils.sanitize_dict(target, fields) ++ if target.get('properties'): ++ target['properties'] = strutils.mask_dict_password( ++ target['properties'], "******") ++ ++ + def list_convert_with_links(rpc_targets, limit, url, fields=None, + detail=None, **kwargs): + if detail: +@@ -106,7 +114,7 @@ + limit=limit, + url=url, + fields=fields, +- sanitize_func=api_utils.sanitize_dict, ++ sanitize_func=target_sanitize, + **kwargs + ) + +@@ -401,9 +409,15 @@ + new_target = api.request.rpcapi.update_volume_target( + context, rpc_target, topic) + +- api_target = convert_with_links(new_target) + notify.emit_end_notification(context, new_target, 'update', + node_uuid=rpc_node.uuid) ++ ++ cdict = api.request.context.to_policy_values() ++ if not policy.check_policy('baremetal:volume:view_target_properties', ++ cdict, cdict): ++ self._redact_target_properties(new_target) ++ ++ api_target = convert_with_links(new_target) + return api_target + + @METRICS.timer('VolumeTargetsController.delete') +diff --git a/ironic/tests/unit/api/controllers/v1/test_volume_target.py b/ironic/tests/unit/api/controllers/v1/test_volume_target.py +index 038f3cb..af33e2d 100644 +--- a/ironic/tests/unit/api/controllers/v1/test_volume_target.py ++++ b/ironic/tests/unit/api/controllers/v1/test_volume_target.py +@@ -29,6 +29,7 @@ + from ironic.api.controllers.v1 import notification_utils + from ironic.api.controllers.v1 import utils as api_utils + from ironic.common import exception ++from ironic.common import policy + from ironic.conductor import rpcapi + from ironic import objects + from ironic.objects import fields as obj_fields +@@ -319,6 +320,91 @@ + self.assertEqual('application/json', response.content_type) + self.assertIn(invalid_key, response.json['error_message']) + ++ @mock.patch.object(policy, 'check_policy', autospec=True) ++ def test_get_one_redact_properties(self, mock_check_policy): ++ """Properties are redacted when policy denies access.""" ++ target = obj_utils.create_test_volume_target( ++ self.context, node_id=self.node.id) ++ ++ def check_policy_side_effect(rule, target_dict, creds, ++ *args, **kwargs): ++ if rule == 'baremetal:volume:view_target_properties': ++ return False ++ return True ++ ++ mock_check_policy.side_effect = check_policy_side_effect ++ data = self.get_json('/volume/targets/%s' % target.uuid, ++ headers=self.headers) ++ self.assertIn('properties', data) ++ self.assertIn('redacted_contents', data['properties']) ++ self.assertNotIn('target_iqn', data['properties']) ++ ++ @mock.patch.object(policy, 'check_policy', autospec=True) ++ def test_get_one_no_redact_properties(self, mock_check_policy): ++ """Properties are visible when policy allows access.""" ++ target = obj_utils.create_test_volume_target( ++ self.context, node_id=self.node.id) ++ ++ def check_policy_side_effect(rule, target_dict, creds, ++ *args, **kwargs): ++ if rule == 'baremetal:volume:view_target_properties': ++ return True ++ return True ++ ++ mock_check_policy.side_effect = check_policy_side_effect ++ data = self.get_json('/volume/targets/%s' % target.uuid, ++ headers=self.headers) ++ self.assertIn('properties', data) ++ self.assertEqual({'target_iqn': 'iqn.foo'}, ++ data['properties']) ++ ++ @mock.patch.object(policy, 'check_policy', autospec=True) ++ def test_detail_redact_properties(self, mock_check_policy): ++ """Properties are redacted in detail listing.""" ++ obj_utils.create_test_volume_target( ++ self.context, node_id=self.node.id) ++ ++ def check_policy_side_effect(rule, target_dict, creds, ++ *args, **kwargs): ++ if rule == 'baremetal:volume:view_target_properties': ++ return False ++ return True ++ ++ mock_check_policy.side_effect = check_policy_side_effect ++ data = self.get_json('/volume/targets?detail=True', ++ headers=self.headers) ++ props = data['targets'][0]['properties'] ++ self.assertIn('redacted_contents', props) ++ self.assertNotIn('target_iqn', props) ++ ++ def test_get_one_masks_secret_properties(self): ++ """Password-like values in properties are always masked.""" ++ props = {'target_iqn': 'iqn.foo', ++ 'auth_password': 'supersecret'} ++ obj_utils.create_test_volume_target( ++ self.context, node_id=self.node.id, ++ properties=props) ++ data = self.get_json('/volume/targets/%s' ++ % dbutils.get_test_volume_target()['uuid'], ++ headers=self.headers) ++ self.assertEqual('iqn.foo', ++ data['properties']['target_iqn']) ++ self.assertEqual('******', ++ data['properties']['auth_password']) ++ ++ def test_detail_masks_secret_properties(self): ++ """Password-like values in properties are masked in listings.""" ++ props = {'target_iqn': 'iqn.foo', ++ 'auth_password': 'supersecret'} ++ obj_utils.create_test_volume_target( ++ self.context, node_id=self.node.id, ++ properties=props) ++ data = self.get_json('/volume/targets?detail=True', ++ headers=self.headers) ++ result_props = data['targets'][0]['properties'] ++ self.assertEqual('iqn.foo', result_props['target_iqn']) ++ self.assertEqual('******', result_props['auth_password']) ++ + @mock.patch.object(api_utils, 'get_rpc_node', autospec=True) + def test_get_all_by_node_name_ok(self, mock_get_rpc_node): + # GET /v1/volume/targets specifying node_name - success +@@ -727,6 +813,86 @@ + self.assertTrue(response.json['error_message']) + self.assertFalse(mock_upd.called) + ++ @mock.patch.object(policy, 'check_policy', autospec=True) ++ @mock.patch.object(notification_utils, '_emit_api_notification', ++ autospec=True) ++ def test_patch_redact_properties(self, mock_notify, mock_check_policy, ++ mock_upd): ++ """Properties are redacted in PATCH response.""" ++ props = {'target_iqn': 'iqn.secret'} ++ mock_upd.return_value = self.target ++ mock_upd.return_value.properties = props ++ ++ def check_policy_side_effect(rule, target_dict, creds, ++ *args, **kwargs): ++ if rule == 'baremetal:volume:view_target_properties': ++ return False ++ return True ++ ++ mock_check_policy.side_effect = check_policy_side_effect ++ response = self.patch_json('/volume/targets/%s' ++ % self.target.uuid, ++ [{'path': '/extra/foo', ++ 'value': 'bar', ++ 'op': 'add'}], ++ headers=self.headers) ++ self.assertEqual(http_client.OK, response.status_code) ++ self.assertIn('redacted_contents', ++ response.json['properties']) ++ self.assertNotIn('target_iqn', ++ response.json['properties']) ++ mock_notify.assert_has_calls( ++ [mock.call(mock.ANY, mock.ANY, 'update', ++ obj_fields.NotificationLevel.INFO, ++ obj_fields.NotificationStatus.START, ++ node_uuid=self.node.uuid), ++ mock.call(mock.ANY, mock.ANY, 'update', ++ obj_fields.NotificationLevel.INFO, ++ obj_fields.NotificationStatus.END, ++ node_uuid=self.node.uuid)]) ++ ++ @mock.patch.object(policy, 'check_policy', autospec=True) ++ def test_patch_no_redact_properties(self, mock_check_policy, ++ mock_upd): ++ """Properties are visible in PATCH response when allowed.""" ++ props = {'target_iqn': 'iqn.secret'} ++ mock_upd.return_value = self.target ++ mock_upd.return_value.properties = props ++ ++ def check_policy_side_effect(rule, target_dict, creds, ++ *args, **kwargs): ++ if rule == 'baremetal:volume:view_target_properties': ++ return True ++ return True ++ ++ mock_check_policy.side_effect = check_policy_side_effect ++ response = self.patch_json('/volume/targets/%s' ++ % self.target.uuid, ++ [{'path': '/extra/foo', ++ 'value': 'bar', ++ 'op': 'add'}], ++ headers=self.headers) ++ self.assertEqual(http_client.OK, response.status_code) ++ self.assertEqual(props, response.json['properties']) ++ ++ def test_patch_masks_secret_properties(self, mock_upd): ++ """Password-like values in properties are masked in PATCH.""" ++ props = {'target_iqn': 'iqn.foo', ++ 'auth_password': 'supersecret'} ++ mock_upd.return_value = self.target ++ mock_upd.return_value.properties = props ++ response = self.patch_json('/volume/targets/%s' ++ % self.target.uuid, ++ [{'path': '/extra/foo', ++ 'value': 'bar', ++ 'op': 'add'}], ++ headers=self.headers) ++ self.assertEqual(http_client.OK, response.status_code) ++ self.assertEqual('iqn.foo', ++ response.json['properties']['target_iqn']) ++ self.assertEqual('******', ++ response.json['properties']['auth_password']) ++ + + class TestPost(test_api_base.BaseApiTest): + headers = {api_base.Version.string: str(api_v1.max_version())} +diff --git a/releasenotes/notes/volume-target-redact-sensitive-properties-041818005cac008f.yaml b/releasenotes/notes/volume-target-redact-sensitive-properties-041818005cac008f.yaml +new file mode 100644 +index 0000000..5d1a04b +--- /dev/null ++++ b/releasenotes/notes/volume-target-redact-sensitive-properties-041818005cac008f.yaml +@@ -0,0 +1,21 @@ ++--- ++security: ++ - | ++ The volume target ``properties`` field can contain sensitive iSCSI ++ connection details, including CHAP authentication credentials. A ++ contributor identified that the ``baremetal:volume:view_target_properties`` ++ RBAC policy check was not applied to ``PATCH`` responses. During review, ++ the ironic core security team determined that RBAC policy enforcement ++ alone was insufficient since standalone deployments do not have RBAC ++ support. The fix was updated in line with code reviewer feedback to ++ add secondary scrubbing of the ``properties`` field in all cases using ++ ``mask_dict_password``, ensuring password-like values are masked in all ++ API responses unconditionally. ++fixes: ++ - | ++ The ``PATCH`` endpoint for volume targets now applies the ++ ``baremetal:volume:view_target_properties`` RBAC policy check to its ++ response payload, consistent with the ``GET`` endpoints. Additionally, ++ the ``properties`` field is now scrubbed using ``mask_dict_password`` ++ to mask any password-like keys, providing defense-in-depth independent ++ of RBAC policy enforcement. diff -Nru ironic-29.0.5/debian/patches/CVE-2026-54423-Add_operator_configurable_step_disallow_lists.patch ironic-29.0.5/debian/patches/CVE-2026-54423-Add_operator_configurable_step_disallow_lists.patch --- ironic-29.0.5/debian/patches/CVE-2026-54423-Add_operator_configurable_step_disallow_lists.patch 1970-01-01 00:00:00.000000000 +0000 +++ ironic-29.0.5/debian/patches/CVE-2026-54423-Add_operator_configurable_step_disallow_lists.patch 2026-06-16 10:46:11.000000000 +0000 @@ -0,0 +1,921 @@ +Author: Himanshu Roy +Date: Mon, 18 May 2026 23:53:02 +0530 +Description: Add operator-configurable step disallow lists + Add [api]disallow_deploy_steps, disallow_clean_steps, and + disallow_service_steps config options. Steps are blocked at the + API layer (with error) and filtered at the conductor layer + (silently) to cover all paths: manual, automated, templates, + and runbooks. + . + note(JayF) This backport contained some tests which depended + on the changes to allow network_interfaces to have service steps. + Those tests have been removed. + . + note(JayF) When backporting 2025.2->2025.1, we also had to remove + an additional test which does not apply to this branch. +Bug: https://launchpad.net/bugs/2150456 +Bug-Debian: https://bugs.debian.org/1141717 +Change-Id: I54d854f81cd6e8ee2d6738c37df3f61ea9341b34 +Assisted-By: Claude Opus 4.6 +Signed-off-by: Himanshu Roy +Signed-off-by: Jay Faulkner +Origin: upstream, https://review.opendev.org/c/openstack/ironic/+/992732 +Last-Update: 2026-06-26 + +diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py +index 65224ab..9ea4b7a 100644 +--- a/ironic/api/controllers/v1/node.py ++++ b/ironic/api/controllers/v1/node.py +@@ -1327,6 +1327,14 @@ + """ + _check_steps(clean_steps, 'clean', _STEPS_SCHEMA) + ++ disallowed_steps = CONF.api.disallow_clean_steps ++ if disallowed_steps: ++ for step in clean_steps: ++ step_id = '%s.%s' % (step['interface'], step['step']) ++ if step_id in disallowed_steps: ++ raise exception.StepNotAllowed(step=step_id, ++ step_type='clean') ++ + + def _check_deploy_steps(deploy_steps): + """Ensure all necessary keys are present and correct in steps for deploy +@@ -1337,6 +1345,14 @@ + """ + _check_steps(deploy_steps, 'deploy', _DEPLOY_STEPS_SCHEMA) + ++ disallowed_steps = CONF.api.disallow_deploy_steps ++ if disallowed_steps: ++ for step in deploy_steps: ++ step_id = '%s.%s' % (step['interface'], step['step']) ++ if step_id in disallowed_steps: ++ raise exception.StepNotAllowed(step=step_id, ++ step_type='deploy') ++ + + def _check_service_steps(service_steps): + """Ensure all necessary keys are present and correct in steps for service +@@ -1347,6 +1363,14 @@ + """ + _check_steps(service_steps, 'service', _STEPS_SCHEMA) + ++ disallowed_steps = CONF.api.disallow_service_steps ++ if disallowed_steps: ++ for step in service_steps: ++ step_id = '%s.%s' % (step['interface'], step['step']) ++ if step_id in disallowed_steps: ++ raise exception.StepNotAllowed(step=step_id, ++ step_type='service') ++ + + def _check_steps(steps, step_type, schema): + """Ensure all necessary keys are present and correct in steps. +diff --git a/ironic/common/exception.py b/ironic/common/exception.py +index 23b3a23..05d24bd 100644 +--- a/ironic/common/exception.py ++++ b/ironic/common/exception.py +@@ -1006,6 +1006,13 @@ + _msg_fmt = _("'%(mode)s' boot mode is not allowed for %(op)s operation.") + + ++class StepNotAllowed(Invalid): ++ _msg_fmt = _("%(step_type)s step '%(step)s' is not allowed. Disallowed " ++ "by operator configuration " ++ "[api]disallow_%(step_type)s_steps.") ++ code = http_client.BAD_REQUEST ++ ++ + class InvalidImage(ImageUnacceptable): + _msg_fmt = _("The requested image is not valid for use.") + +diff --git a/ironic/conductor/manager.py b/ironic/conductor/manager.py +index 1d6b4db..20861fe 100644 +--- a/ironic/conductor/manager.py ++++ b/ironic/conductor/manager.py +@@ -895,7 +895,8 @@ + exception.InstanceDeployFailure, + exception.InvalidStateRequested, + exception.NodeProtected, +- exception.ConcurrentActionLimit) ++ exception.ConcurrentActionLimit, ++ exception.StepNotAllowed) + def do_node_deploy(self, context, node_id, rebuild=False, + configdrive=None, deploy_steps=None): + """RPC method to initiate deployment to a node. +@@ -933,6 +934,12 @@ + with task_manager.acquire(context, node_id, shared=False, + purpose='node deployment') as task: + deployments.validate_node(task, event=event) ++ # Check user-provided deploy steps against the disallow ++ # list before transitioning state so the node stays in ++ # its current state on rejection. ++ if deploy_steps: ++ conductor_steps.check_disallowed_steps( ++ deploy_steps, 'deploy', raise_on_disallowed=True) + deployments.start_deploy(task, self, configdrive, event=event, + deploy_steps=deploy_steps) + +@@ -1178,7 +1185,8 @@ + exception.NodeInMaintenance, + exception.NodeLocked, + exception.NoFreeConductorWorker, +- exception.ConcurrentActionLimit) ++ exception.ConcurrentActionLimit, ++ exception.StepNotAllowed) + def do_node_clean(self, context, node_id, clean_steps, + disable_ramdisk=False): + """RPC method to initiate manual cleaning. +@@ -1232,6 +1240,12 @@ + {'node': node.uuid, 'msg': e}) + raise exception.InvalidParameterValue(msg) + ++ # Check user-provided clean steps against the disallow ++ # list before transitioning state so the node stays in ++ # its current state on rejection. ++ conductor_steps.check_disallowed_steps( ++ clean_steps, 'clean', raise_on_disallowed=True) ++ + try: + task.process_event( + 'clean', +@@ -3806,7 +3820,8 @@ + exception.NodeInMaintenance, + exception.NodeLocked, + exception.NoFreeConductorWorker, +- exception.ConcurrentActionLimit) ++ exception.ConcurrentActionLimit, ++ exception.StepNotAllowed) + def do_node_service(self, context, node_id, service_steps, + disable_ramdisk=False): + """RPC method to initiate node service. +@@ -3858,6 +3873,13 @@ + 'failed: %(msg)s') % + {'node': node.uuid, 'msg': e}) + raise exception.InvalidParameterValue(msg) ++ ++ # Check user-provided service steps against the disallow ++ # list before transitioning state so the node stays in ++ # its current state on rejection. ++ conductor_steps.check_disallowed_steps( ++ service_steps, 'service', raise_on_disallowed=True) ++ + try: + task.process_event( + 'service', +diff --git a/ironic/conductor/steps.py b/ironic/conductor/steps.py +index 79a23ed..6552b31 100644 +--- a/ironic/conductor/steps.py ++++ b/ironic/conductor/steps.py +@@ -325,6 +325,12 @@ + task, node.driver_internal_info['clean_steps'], + disable_ramdisk=disable_ramdisk) + ++ # Filter out operator-disallowed clean steps ++ disallowed = CONF.api.disallow_clean_steps ++ if disallowed: ++ steps = [s for s in steps ++ if step_id(s) not in disallowed] ++ + LOG.debug('List of the steps for %(type)s cleaning of node %(node)s: ' + '%(steps)s', {'type': 'manual' if manual_clean else 'automated', + 'node': node.uuid, +@@ -464,8 +470,15 @@ + deployment steps. + """ + node = task.node +- node.set_driver_internal_info('deploy_steps', _get_all_deployment_steps( +- task, skip_missing=skip_missing)) ++ steps = _get_all_deployment_steps(task, skip_missing=skip_missing) ++ ++ # Filter out operator-disallowed deploy steps ++ disallowed = CONF.api.disallow_deploy_steps ++ if disallowed: ++ steps = [s for s in steps ++ if step_id(s) not in disallowed] ++ ++ node.set_driver_internal_info('deploy_steps', steps) + + LOG.debug('List of the deploy steps for node %(node)s: %(steps)s', { + 'node': node.uuid, +@@ -495,6 +508,13 @@ + steps = _validate_user_service_steps( + task, node.driver_internal_info.get('service_steps', []), + disable_ramdisk=disable_ramdisk) ++ ++ # Filter out operator-disallowed service steps ++ disallowed = CONF.api.disallow_service_steps ++ if disallowed: ++ steps = [s for s in steps ++ if step_id(s) not in disallowed] ++ + LOG.debug('List of the steps for service of node %(node)s: ' + '%(steps)s', {'node': node.uuid, + 'steps': steps}) +@@ -516,6 +536,40 @@ + return '.'.join([step['interface'], step['step']]) + + ++def check_disallowed_steps(steps, step_type, raise_on_disallowed=True): ++ """Check and optionally filter steps against the operator disallow list. ++ ++ When raise_on_disallowed is True (user-provided steps), raises ++ StepNotAllowed for the first disallowed step found. When False ++ (automated / runbook steps), silently filters them out. ++ ++ :param steps: list of step dicts, or None. ++ :param step_type: one of 'clean', 'deploy', 'service'. ++ :param raise_on_disallowed: if True, raise on first disallowed step; ++ if False, silently remove disallowed steps. ++ :returns: the (possibly filtered) list of steps, or None. ++ :raises: StepNotAllowed if raise_on_disallowed and a step is disallowed. ++ """ ++ if steps is None: ++ return None ++ config_map = { ++ 'clean': CONF.api.disallow_clean_steps, ++ 'deploy': CONF.api.disallow_deploy_steps, ++ 'service': CONF.api.disallow_service_steps, ++ } ++ disallowed = config_map[step_type] ++ if not disallowed: ++ return steps ++ if raise_on_disallowed: ++ for step in steps: ++ sid = step_id(step) ++ if sid in disallowed: ++ raise exception.StepNotAllowed(step=sid, step_type=step_type) ++ return steps ++ else: ++ return [s for s in steps if step_id(s) not in disallowed] ++ ++ + def _validate_deploy_steps_unique(user_steps): + """Validate that deploy steps from deploy templates are unique. + +diff --git a/ironic/conf/api.py b/ironic/conf/api.py +index b04048d..450695a 100644 +--- a/ironic/conf/api.py ++++ b/ironic/conf/api.py +@@ -103,6 +103,77 @@ + mutable=True, + help=_("Specifies a list of boot modes that are not allowed " + "during enrollment. Eg: ['bios']")), ++ cfg.ListOpt('disallow_deploy_steps', ++ default=[], ++ mutable=True, ++ help=_("List of steps not allowed across the deploy " ++ "workflow. Each entry should be in 'interface.step' " ++ "format, e.g. ['raid.apply_configuration']. " ++ "Applies to user-requested steps, deploy template " ++ "steps, and driver steps alike.")), ++ cfg.ListOpt('disallow_service_steps', ++ default=[], ++ mutable=True, ++ help=_("List of steps not allowed across the service " ++ "workflow. Each entry should be in 'interface.step' " ++ "format, e.g. " ++ "['bios.factory_reset','bios.apply_configuration']. " ++ "Applies to user-requested steps and driver steps " ++ "alike.")), ++ cfg.ListOpt('disallow_clean_steps', ++ default=[], ++ mutable=True, ++ help=_("List of steps not allowed across the clean " ++ "workflow. Each entry should be in 'interface.step' " ++ "format, e.g. " ++ "['bios.factory_reset','bios.apply_configuration']. " ++ "Applies to user-requested (manual) steps, " ++ "automated cleaning steps, and runbook steps " ++ "alike.")), ++ cfg.IntOpt('max_json_body_depth', ++ default=25, ++ min=8, ++ mutable=True, ++ help=_('Maximum JSON nesting depth allowed in API ' ++ 'request bodies. Requests exceeding this ' ++ 'depth are rejected with HTTP 400 to prevent ' ++ 'recursion-based crashes in the JSON parser. ' ++ 'The deepest known legitimate structure in ' ++ 'the Ironic API is approximately 7 levels ' ++ '(configdrive network_data).')), ++ cfg.IntOpt('max_json_body_size', ++ default=1024, ++ min=4, ++ mutable=True, ++ help=_('Maximum size of a JSON request body, ' ++ 'in KiB. Requests with a Content-Length ' ++ 'exceeding this value are rejected with ' ++ 'HTTP 413 before the body is read into ' ++ 'memory. The node provision and inspection ' ++ 'endpoints use the separate ' ++ '[api]max_json_body_size_provision and ' ++ '[api]max_json_body_size_inspection limits ' ++ 'respectively. Defaults to 1 MiB.')), ++ cfg.IntOpt('max_json_body_size_provision', ++ default=65536, ++ min=4, ++ mutable=True, ++ help=_('Maximum size of a JSON request body ' ++ 'for the node provision state endpoint, ' ++ 'in KiB. This endpoint may carry ' ++ 'configdrive data and deploy steps. ' ++ 'Defaults to 64 MiB.')), ++ cfg.IntOpt('max_json_body_size_inspection', ++ default=16384, ++ min=4, ++ mutable=True, ++ help=_('Maximum size of a JSON request body ' ++ 'for the continue_inspection endpoint, ' ++ 'in KiB. Inspection data from the ' ++ 'ramdisk may include system logs such ' ++ 'as the journal, making the payload ' ++ 'significantly larger than normal API ' ++ 'requests. Defaults to 16 MiB.')), + ] + + opt_group = cfg.OptGroup(name='api', +diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py +index 9aeec84..d7f0fe0 100644 +--- a/ironic/tests/unit/api/controllers/v1/test_node.py ++++ b/ironic/tests/unit/api/controllers/v1/test_node.py +@@ -7521,6 +7521,114 @@ + self.assertEqual(3, check_mock.call_count) + + ++class TestCheckDisallowedSteps(db_base.DbTestCase): ++ ++ def test__check_clean_steps_disallowed(self): ++ self.config(disallow_clean_steps=['raid.create_configuration'], ++ group='api') ++ clean_steps = [{"interface": "raid", "step": "create_configuration"}] ++ self.assertRaisesRegex(exception.StepNotAllowed, ++ "clean step 'raid.create_configuration' " ++ "is not allowed", ++ api_node._check_clean_steps, clean_steps) ++ ++ def test__check_clean_steps_disallowed_not_in_list(self): ++ self.config(disallow_clean_steps=['raid.create_configuration'], ++ group='api') ++ clean_steps = [{"interface": "raid", "step": "delete_configuration"}] ++ api_node._check_clean_steps(clean_steps) ++ ++ def test__check_clean_steps_disallowed_empty(self): ++ self.config(disallow_clean_steps=[], group='api') ++ clean_steps = [{"interface": "raid", "step": "create_configuration"}] ++ api_node._check_clean_steps(clean_steps) ++ ++ def test__check_clean_steps_disallowed_multiple(self): ++ self.config(disallow_clean_steps=['raid.create_configuration', ++ 'bios.factory_reset'], ++ group='api') ++ clean_steps = [{"interface": "bios", "step": "factory_reset"}] ++ self.assertRaisesRegex(exception.StepNotAllowed, ++ "clean step 'bios.factory_reset' " ++ "is not allowed", ++ api_node._check_clean_steps, clean_steps) ++ ++ def test__check_deploy_steps_disallowed(self): ++ self.config(disallow_deploy_steps=['raid.create_configuration'], ++ group='api') ++ deploy_steps = [{"interface": "raid", "step": "create_configuration", ++ "args": {}, "priority": 100}] ++ self.assertRaisesRegex(exception.StepNotAllowed, ++ "deploy step 'raid.create_configuration' " ++ "is not allowed", ++ api_node._check_deploy_steps, deploy_steps) ++ ++ def test__check_deploy_steps_disallowed_not_in_list(self): ++ self.config(disallow_deploy_steps=['raid.create_configuration'], ++ group='api') ++ deploy_steps = [{"interface": "raid", "step": "delete_configuration", ++ "args": {}, "priority": 100}] ++ api_node._check_deploy_steps(deploy_steps) ++ ++ def test__check_deploy_steps_disallowed_empty(self): ++ self.config(disallow_deploy_steps=[], group='api') ++ deploy_steps = [{"interface": "raid", "step": "create_configuration", ++ "args": {}, "priority": 100}] ++ api_node._check_deploy_steps(deploy_steps) ++ ++ def test__check_service_steps_disallowed(self): ++ self.config(disallow_service_steps=['bios.apply_configuration'], ++ group='api') ++ service_steps = [{"interface": "bios", ++ "step": "apply_configuration"}] ++ self.assertRaisesRegex(exception.StepNotAllowed, ++ "service step 'bios.apply_configuration' " ++ "is not allowed", ++ api_node._check_service_steps, service_steps) ++ ++ def test__check_service_steps_disallowed_not_in_list(self): ++ self.config(disallow_service_steps=['bios.apply_configuration'], ++ group='api') ++ service_steps = [{"interface": "bios", "step": "factory_reset"}] ++ api_node._check_service_steps(service_steps) ++ ++ def test__check_service_steps_disallowed_empty(self): ++ self.config(disallow_service_steps=[], group='api') ++ service_steps = [{"interface": "bios", ++ "step": "apply_configuration"}] ++ api_node._check_service_steps(service_steps) ++ ++ def test__check_disallowed_steps_independent_per_type(self): ++ """Disallow lists are independent per step type.""" ++ self.config(disallow_clean_steps=['bios.factory_reset'], ++ disallow_deploy_steps=[], ++ disallow_service_steps=[], ++ group='api') ++ # Disallowed for clean ++ clean_steps = [{"interface": "bios", "step": "factory_reset"}] ++ self.assertRaisesRegex(exception.StepNotAllowed, ++ "clean step", ++ api_node._check_clean_steps, clean_steps) ++ # Same step is allowed for deploy ++ deploy_steps = [{"interface": "bios", "step": "factory_reset", ++ "args": {}, "priority": 100}] ++ api_node._check_deploy_steps(deploy_steps) ++ # Same step is allowed for service ++ service_steps = [{"interface": "bios", "step": "factory_reset"}] ++ api_node._check_service_steps(service_steps) ++ ++ def test__check_clean_steps_disallowed_schema_validated_first(self): ++ """Schema validation runs before the disallow check.""" ++ self.config(disallow_clean_steps=['raid.create_configuration'], ++ group='api') ++ # Bad schema: missing 'step' key. Should raise InvalidParameterValue ++ # from schema validation, not StepNotAllowed. ++ clean_steps = [{"interface": "raid"}] ++ self.assertRaisesRegex(exception.InvalidParameterValue, ++ 'step', ++ api_node._check_clean_steps, clean_steps) ++ ++ + class TestAttachDetachVif(test_api_base.BaseApiTest): + + def setUp(self): +diff --git a/ironic/tests/unit/conductor/test_manager.py b/ironic/tests/unit/conductor/test_manager.py +index 20a660b..d4b28f4 100644 +--- a/ironic/tests/unit/conductor/test_manager.py ++++ b/ironic/tests/unit/conductor/test_manager.py +@@ -2151,6 +2151,49 @@ + mock_iwdi.assert_called_once_with(self.context, node.instance_info) + self.assertFalse(node.driver_internal_info['is_whole_disk_image']) + ++ @mock.patch.object(deployments, 'start_deploy', autospec=True) ++ def test_do_node_deploy_disallowed_step_raises(self, mock_start, ++ mock_iwdi): ++ """Disallowed deploy step raises before state transition.""" ++ self.config(disallow_deploy_steps=['bios.factory_reset'], group='api') ++ mock_iwdi.return_value = False ++ self._start_service() ++ deploy_steps = [{'interface': 'bios', 'step': 'factory_reset', ++ 'priority': 95}] ++ node = obj_utils.create_test_node( ++ self.context, driver='fake-hardware', ++ provision_state=states.AVAILABLE, ++ target_provision_state=states.NOSTATE) ++ exc = self.assertRaises(messaging.rpc.ExpectedException, ++ self.service.do_node_deploy, ++ self.context, node.uuid, ++ deploy_steps=deploy_steps) ++ self.assertEqual(exception.StepNotAllowed, exc.exc_info[0]) ++ # start_deploy must NOT have been called ++ self.assertFalse(mock_start.called) ++ node.refresh() ++ self.assertEqual(states.AVAILABLE, node.provision_state) ++ ++ @mock.patch.object(deployments, 'start_deploy', autospec=True) ++ def test_do_node_deploy_allowed_step_proceeds(self, mock_start, ++ mock_iwdi): ++ """Allowed deploy step proceeds normally.""" ++ self.config(disallow_deploy_steps=['raid.create_configuration'], ++ group='api') ++ mock_iwdi.return_value = False ++ self._start_service() ++ deploy_steps = [{'interface': 'bios', 'step': 'factory_reset', ++ 'priority': 95}] ++ node = obj_utils.create_test_node( ++ self.context, driver='fake-hardware', ++ provision_state=states.AVAILABLE, ++ target_provision_state=states.NOSTATE) ++ self.service.do_node_deploy(self.context, node.uuid, ++ deploy_steps=deploy_steps) ++ mock_start.assert_called_once_with( ++ mock.ANY, mock.ANY, None, event='deploy', ++ deploy_steps=deploy_steps) ++ + + @mgr_utils.mock_record_keepalive + class ContinueNodeDeployTestCase(mgr_utils.ServiceSetUpMixin, +@@ -3220,6 +3263,60 @@ + self.assertEqual(states.DEPLOYING, node.provision_state) + self._stop_service() + ++ @mock.patch('ironic.conductor.task_manager.TaskManager.process_event', ++ autospec=True) ++ @mock.patch('ironic.drivers.modules.network.flat.FlatNetwork.validate', ++ autospec=True) ++ @mock.patch('ironic.drivers.modules.fake.FakePower.validate', ++ autospec=True) ++ def test_do_node_clean_disallowed_step_raises(self, mock_power_valid, ++ mock_network_valid, ++ mock_process): ++ """Disallowed manual clean step raises before state transition.""" ++ self.config(disallow_clean_steps=['deploy.build_raid'], group='api') ++ node = obj_utils.create_test_node( ++ self.context, driver='fake-hardware', ++ provision_state=states.MANAGEABLE, ++ target_provision_state=states.NOSTATE) ++ self._start_service() ++ clean_steps = [self.deploy_raid] ++ exc = self.assertRaises(messaging.rpc.ExpectedException, ++ self.service.do_node_clean, ++ self.context, node.uuid, clean_steps) ++ self.assertEqual(exception.StepNotAllowed, exc.exc_info[0]) ++ # process_event must NOT have been called ++ self.assertFalse(mock_process.called) ++ node.refresh() ++ # Node stays in original state ++ self.assertEqual(states.MANAGEABLE, node.provision_state) ++ ++ @mock.patch('ironic.conductor.task_manager.TaskManager.process_event', ++ autospec=True) ++ @mock.patch('ironic.drivers.modules.network.flat.FlatNetwork.validate', ++ autospec=True) ++ @mock.patch('ironic.drivers.modules.fake.FakePower.validate', ++ autospec=True) ++ def test_do_node_clean_allowed_step_proceeds(self, mock_power_valid, ++ mock_network_valid, ++ mock_process): ++ """Allowed clean step proceeds normally.""" ++ self.config(disallow_clean_steps=['raid.create_configuration'], ++ group='api') ++ node = obj_utils.create_test_node( ++ self.context, driver='fake-hardware', ++ provision_state=states.MANAGEABLE, ++ target_provision_state=states.NOSTATE) ++ self._start_service() ++ clean_steps = [self.deploy_raid] ++ self.service.do_node_clean(self.context, node.uuid, clean_steps) ++ mock_process.assert_called_once_with( ++ mock.ANY, ++ 'clean', ++ callback=mock.ANY, ++ call_args=(cleaning.do_node_clean, mock.ANY, ++ clean_steps, False), ++ err_handler=mock.ANY, target_state='manageable') ++ + + @mgr_utils.mock_record_keepalive + class DoNodeServiceTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase): +@@ -3443,6 +3540,57 @@ + self.service, servicing.continue_node_service, mock.ANY) + self._stop_service() + ++ @mock.patch('ironic.conductor.task_manager.TaskManager.process_event', ++ autospec=True) ++ @mock.patch('ironic.drivers.modules.network.flat.FlatNetwork.validate', ++ autospec=True) ++ @mock.patch('ironic.drivers.modules.fake.FakePower.validate', ++ autospec=True) ++ def test_do_node_service_disallowed_step_raises(self, mock_pv, mock_nv, ++ mock_event): ++ """Disallowed service step raises before state transition.""" ++ self.config(disallow_service_steps=['deploy.update_firmware'], ++ group='api') ++ node = obj_utils.create_test_node( ++ self.context, driver='fake-hardware', ++ provision_state=states.ACTIVE, ++ target_provision_state=states.NOSTATE) ++ self._start_service() ++ service_steps = [self.deploy_update] ++ exc = self.assertRaises(messaging.rpc.ExpectedException, ++ self.service.do_node_service, ++ self.context, node.uuid, service_steps) ++ self.assertEqual(exception.StepNotAllowed, exc.exc_info[0]) ++ self.assertFalse(mock_event.called) ++ node.refresh() ++ self.assertEqual(states.ACTIVE, node.provision_state) ++ ++ @mock.patch('ironic.conductor.task_manager.TaskManager.process_event', ++ autospec=True) ++ @mock.patch('ironic.drivers.modules.network.flat.FlatNetwork.validate', ++ autospec=True) ++ @mock.patch('ironic.drivers.modules.fake.FakePower.validate', ++ autospec=True) ++ def test_do_node_service_allowed_step_proceeds(self, mock_pv, mock_nv, ++ mock_event): ++ """Allowed service step proceeds normally.""" ++ self.config(disallow_service_steps=['raid.create_configuration'], ++ group='api') ++ node = obj_utils.create_test_node( ++ self.context, driver='fake-hardware', ++ provision_state=states.ACTIVE, ++ target_provision_state=states.NOSTATE) ++ self._start_service() ++ service_steps = [self.deploy_update] ++ self.service.do_node_service(self.context, node.uuid, service_steps) ++ mock_event.assert_called_once_with( ++ mock.ANY, ++ 'service', ++ callback=mock.ANY, ++ call_args=(servicing.do_node_service, mock.ANY, ++ service_steps, False), ++ err_handler=mock.ANY, target_state='active') ++ + + class DoNodeRescueTestCase(mgr_utils.CommonMixIn, mgr_utils.ServiceSetUpMixin, + db_base.DbTestCase): +diff --git a/ironic/tests/unit/conductor/test_steps.py b/ironic/tests/unit/conductor/test_steps.py +index 8782c4a..3280c27 100644 +--- a/ironic/tests/unit/conductor/test_steps.py ++++ b/ironic/tests/unit/conductor/test_steps.py +@@ -1494,3 +1494,287 @@ + self.assertIsNone( + self.node.driver_internal_info['service_step_index']) + mock_steps.assert_called_once_with(task, [], disable_ramdisk=False) ++ ++ ++class DisallowStepFilteringTestCase(db_base.DbTestCase): ++ """Tests for conductor-level disallow step filtering.""" ++ ++ def setUp(self): ++ super(DisallowStepFilteringTestCase, self).setUp() ++ self.node = obj_utils.create_test_node( ++ self.context, driver='fake-hardware') ++ ++ # Clean steps ++ self.clean_erase = { ++ 'step': 'erase_disks', 'priority': 20, 'interface': 'deploy', ++ 'abortable': True} ++ self.clean_update = { ++ 'step': 'update_firmware', 'priority': 10, 'interface': 'power'} ++ self.clean_steps = [self.clean_erase, self.clean_update] ++ ++ # Deploy steps ++ self.deploy_start = { ++ 'step': 'deploy_start', 'priority': 50, 'interface': 'deploy'} ++ self.deploy_raid = { ++ 'step': 'create_configuration', 'priority': 40, ++ 'interface': 'raid'} ++ self.deploy_steps = [self.deploy_start, self.deploy_raid] ++ ++ @mock.patch.object(conductor_steps, '_validate_user_clean_steps', ++ autospec=True) ++ @mock.patch.object(conductor_steps, '_get_cleaning_steps', autospec=True) ++ def test_set_node_cleaning_steps_automated_filters_disallowed( ++ self, mock_steps, mock_validate): ++ """Automated clean filters out disallowed steps.""" ++ self.config(disallow_clean_steps=['deploy.erase_disks'], group='api') ++ mock_steps.return_value = self.clean_steps ++ ++ node = obj_utils.create_test_node( ++ self.context, driver='fake-hardware', ++ uuid=uuidutils.generate_uuid(), ++ provision_state=states.CLEANING, ++ target_provision_state=states.AVAILABLE) ++ ++ with task_manager.acquire( ++ self.context, node.uuid, shared=False) as task: ++ conductor_steps.set_node_cleaning_steps(task) ++ node.refresh() ++ stored = node.driver_internal_info['clean_steps'] ++ self.assertEqual([self.clean_update], stored) ++ mock_steps.assert_called_once_with(task, enabled=True) ++ ++ @mock.patch.object(conductor_steps, '_validate_user_clean_steps', ++ autospec=True) ++ @mock.patch.object(conductor_steps, '_get_cleaning_steps', autospec=True) ++ def test_set_node_cleaning_steps_manual_filters_disallowed( ++ self, mock_steps, mock_validate): ++ """Manual clean also filters disallowed steps (defense-in-depth).""" ++ self.config(disallow_clean_steps=['power.update_firmware'], ++ group='api') ++ clean_steps = [self.clean_update] ++ mock_validate.return_value = clean_steps ++ ++ node = obj_utils.create_test_node( ++ self.context, driver='fake-hardware', ++ uuid=uuidutils.generate_uuid(), ++ provision_state=states.CLEANING, ++ target_provision_state=states.MANAGEABLE, ++ driver_internal_info={'clean_steps': clean_steps}) ++ ++ with task_manager.acquire( ++ self.context, node.uuid, shared=False) as task: ++ conductor_steps.set_node_cleaning_steps(task) ++ node.refresh() ++ stored = node.driver_internal_info['clean_steps'] ++ self.assertEqual([], stored) ++ ++ @mock.patch.object(conductor_steps, '_validate_user_clean_steps', ++ autospec=True) ++ @mock.patch.object(conductor_steps, '_get_cleaning_steps', autospec=True) ++ def test_set_node_cleaning_steps_empty_disallow_passes_all( ++ self, mock_steps, mock_validate): ++ """Empty disallow list passes all steps through.""" ++ self.config(disallow_clean_steps=[], group='api') ++ mock_steps.return_value = self.clean_steps ++ ++ node = obj_utils.create_test_node( ++ self.context, driver='fake-hardware', ++ uuid=uuidutils.generate_uuid(), ++ provision_state=states.CLEANING, ++ target_provision_state=states.AVAILABLE) ++ ++ with task_manager.acquire( ++ self.context, node.uuid, shared=False) as task: ++ conductor_steps.set_node_cleaning_steps(task) ++ node.refresh() ++ stored = node.driver_internal_info['clean_steps'] ++ self.assertEqual(self.clean_steps, stored) ++ ++ @mock.patch.object(conductor_steps, '_validate_user_clean_steps', ++ autospec=True) ++ @mock.patch.object(conductor_steps, '_get_cleaning_steps', autospec=True) ++ def test_set_node_cleaning_steps_disallow_multiple( ++ self, mock_steps, mock_validate): ++ """Multiple disallowed steps are all filtered.""" ++ self.config(disallow_clean_steps=['deploy.erase_disks', ++ 'power.update_firmware'], ++ group='api') ++ mock_steps.return_value = self.clean_steps ++ ++ node = obj_utils.create_test_node( ++ self.context, driver='fake-hardware', ++ uuid=uuidutils.generate_uuid(), ++ provision_state=states.CLEANING, ++ target_provision_state=states.AVAILABLE) ++ ++ with task_manager.acquire( ++ self.context, node.uuid, shared=False) as task: ++ conductor_steps.set_node_cleaning_steps(task) ++ node.refresh() ++ stored = node.driver_internal_info['clean_steps'] ++ self.assertEqual([], stored) ++ ++ @mock.patch.object(conductor_steps, '_get_all_deployment_steps', ++ autospec=True) ++ def test_set_node_deployment_steps_filters_disallowed(self, mock_steps): ++ """Disallowed deploy steps are filtered before storing.""" ++ self.config(disallow_deploy_steps=['raid.create_configuration'], ++ group='api') ++ mock_steps.return_value = self.deploy_steps ++ ++ with task_manager.acquire( ++ self.context, self.node.uuid, shared=False) as task: ++ conductor_steps.set_node_deployment_steps(task) ++ self.node.refresh() ++ stored = self.node.driver_internal_info['deploy_steps'] ++ self.assertEqual([self.deploy_start], stored) ++ ++ @mock.patch.object(conductor_steps, '_get_all_deployment_steps', ++ autospec=True) ++ def test_set_node_deployment_steps_empty_disallow_passes_all( ++ self, mock_steps): ++ """Empty disallow list passes all steps through.""" ++ self.config(disallow_deploy_steps=[], group='api') ++ mock_steps.return_value = self.deploy_steps ++ ++ with task_manager.acquire( ++ self.context, self.node.uuid, shared=False) as task: ++ conductor_steps.set_node_deployment_steps(task) ++ self.node.refresh() ++ stored = self.node.driver_internal_info['deploy_steps'] ++ self.assertEqual(self.deploy_steps, stored) ++ ++ @mock.patch.object(conductor_steps, '_get_all_deployment_steps', ++ autospec=True) ++ def test_set_node_deployment_steps_nonmatching_disallow(self, mock_steps): ++ """Non-matching disallow list leaves steps unchanged.""" ++ self.config(disallow_deploy_steps=['bios.factory_reset'], group='api') ++ mock_steps.return_value = self.deploy_steps ++ ++ with task_manager.acquire( ++ self.context, self.node.uuid, shared=False) as task: ++ conductor_steps.set_node_deployment_steps(task) ++ self.node.refresh() ++ stored = self.node.driver_internal_info['deploy_steps'] ++ self.assertEqual(self.deploy_steps, stored) ++ ++ @mock.patch.object(conductor_steps, '_validate_user_service_steps', ++ autospec=True) ++ def test_set_node_service_steps_filters_disallowed(self, mock_validate): ++ """Disallowed service steps are filtered before storing.""" ++ self.config(disallow_service_steps=['bios.apply_configuration'], ++ group='api') ++ service_steps = [ ++ {'step': 'apply_configuration', 'priority': 50, ++ 'interface': 'bios'}, ++ {'step': 'factory_reset', 'priority': 40, 'interface': 'bios'}, ++ ] ++ mock_validate.return_value = service_steps ++ ++ node = obj_utils.create_test_node( ++ self.context, driver='fake-hardware', ++ uuid=uuidutils.generate_uuid(), ++ driver_internal_info={'service_steps': service_steps}) ++ ++ with task_manager.acquire( ++ self.context, node.uuid, shared=False) as task: ++ conductor_steps.set_node_service_steps(task) ++ node.refresh() ++ stored = node.driver_internal_info['service_steps'] ++ self.assertEqual([service_steps[1]], stored) ++ ++ ++class CheckDisallowedStepsTestCase(db_base.DbTestCase): ++ """Tests for the check_disallowed_steps() helper.""" ++ ++ def setUp(self): ++ super(CheckDisallowedStepsTestCase, self).setUp() ++ self.steps = [ ++ {'interface': 'deploy', 'step': 'erase_disks'}, ++ {'interface': 'power', 'step': 'update_firmware'}, ++ {'interface': 'bios', 'step': 'factory_reset'}, ++ ] ++ ++ def test_none_steps_returns_none(self): ++ result = conductor_steps.check_disallowed_steps( ++ None, 'clean', raise_on_disallowed=True) ++ self.assertIsNone(result) ++ ++ def test_empty_disallow_list_passes_all(self): ++ self.config(disallow_clean_steps=[], group='api') ++ result = conductor_steps.check_disallowed_steps( ++ self.steps, 'clean', raise_on_disallowed=True) ++ self.assertEqual(self.steps, result) ++ ++ def test_raise_on_disallowed_clean(self): ++ self.config(disallow_clean_steps=['deploy.erase_disks'], group='api') ++ self.assertRaises( ++ exception.StepNotAllowed, ++ conductor_steps.check_disallowed_steps, ++ self.steps, 'clean', raise_on_disallowed=True) ++ ++ def test_raise_on_disallowed_deploy(self): ++ self.config(disallow_deploy_steps=['power.update_firmware'], ++ group='api') ++ self.assertRaises( ++ exception.StepNotAllowed, ++ conductor_steps.check_disallowed_steps, ++ self.steps, 'deploy', raise_on_disallowed=True) ++ ++ def test_raise_on_disallowed_service(self): ++ self.config(disallow_service_steps=['bios.factory_reset'], ++ group='api') ++ self.assertRaises( ++ exception.StepNotAllowed, ++ conductor_steps.check_disallowed_steps, ++ self.steps, 'service', raise_on_disallowed=True) ++ ++ def test_filter_silently(self): ++ self.config(disallow_clean_steps=['deploy.erase_disks'], group='api') ++ result = conductor_steps.check_disallowed_steps( ++ self.steps, 'clean', raise_on_disallowed=False) ++ self.assertEqual( ++ [self.steps[1], self.steps[2]], result) ++ ++ def test_filter_silently_multiple(self): ++ self.config(disallow_clean_steps=['deploy.erase_disks', ++ 'bios.factory_reset'], ++ group='api') ++ result = conductor_steps.check_disallowed_steps( ++ self.steps, 'clean', raise_on_disallowed=False) ++ self.assertEqual([self.steps[1]], result) ++ ++ def test_filter_silently_all_disallowed(self): ++ self.config(disallow_clean_steps=['deploy.erase_disks', ++ 'power.update_firmware', ++ 'bios.factory_reset'], ++ group='api') ++ result = conductor_steps.check_disallowed_steps( ++ self.steps, 'clean', raise_on_disallowed=False) ++ self.assertEqual([], result) ++ ++ def test_nonmatching_disallow_passes_all(self): ++ self.config(disallow_clean_steps=['raid.create_configuration'], ++ group='api') ++ result = conductor_steps.check_disallowed_steps( ++ self.steps, 'clean', raise_on_disallowed=True) ++ self.assertEqual(self.steps, result) ++ ++ def test_raise_reports_first_disallowed(self): ++ self.config(disallow_clean_steps=['deploy.erase_disks', ++ 'power.update_firmware'], ++ group='api') ++ exc = self.assertRaises( ++ exception.StepNotAllowed, ++ conductor_steps.check_disallowed_steps, ++ self.steps, 'clean', raise_on_disallowed=True) ++ self.assertIn('deploy.erase_disks', str(exc)) ++ ++ def test_disallow_lists_independent_per_type(self): ++ """Disallow list for one type does not affect another.""" ++ self.config(disallow_clean_steps=['deploy.erase_disks'], group='api') ++ self.config(disallow_deploy_steps=[], group='api') ++ # Should pass for deploy type even though clean disallows it ++ result = conductor_steps.check_disallowed_steps( ++ self.steps, 'deploy', raise_on_disallowed=True) ++ self.assertEqual(self.steps, result) diff -Nru ironic-29.0.5/debian/patches/CVE-2026-54423-block_vendor.send_raw.patch ironic-29.0.5/debian/patches/CVE-2026-54423-block_vendor.send_raw.patch --- ironic-29.0.5/debian/patches/CVE-2026-54423-block_vendor.send_raw.patch 1970-01-01 00:00:00.000000000 +0000 +++ ironic-29.0.5/debian/patches/CVE-2026-54423-block_vendor.send_raw.patch 2026-06-16 10:46:11.000000000 +0000 @@ -0,0 +1,214 @@ +Author: Julia Kreger +Date: Tue, 9 Jun 2026 12:43:41 -0700 +Description: security: block vendor.send_raw (CVE-2026-54423) + A recent security report highlighted issues with the ability + to invoke the ipmitool vendor pass-thru interface method + of ``send_raw``. + . + Where in the step model, this becomes "$interface_name"."$method_name", + hence, "vendor.send_raw". + . + This patch disables the method because standalone operators + do not have the Role Based Access Control model to fallback + on in order to prevent misuse of the interface. While the + interface may still be useful, this disables the ability for + standard API users to invoke the method by default. + . + NOTE(JayF): Resolved some conflicts in ironic/conf/api.py + . +Bug: https://launchpad.net/bugs/2150458 +Bug-Debian: https://bugs.debian.org/1141717 +Change-Id: I1f88315346ff19a4fcde34f954350b32cf02f96c +Signed-off-by: Julia Kreger +Signed-off-by: Jay Faulkner +Origin: pre-OSSA mailing list +Last-Update: 2026-06-12 + +diff --git a/ironic/conf/api.py b/ironic/conf/api.py +index 450695a90..3e27c0b4f 100644 +--- a/ironic/conf/api.py ++++ b/ironic/conf/api.py +@@ -104,7 +104,7 @@ opts = [ + help=_("Specifies a list of boot modes that are not allowed " + "during enrollment. Eg: ['bios']")), + cfg.ListOpt('disallow_deploy_steps', +- default=[], ++ default=['vendor.send_raw'], + mutable=True, + help=_("List of steps not allowed across the deploy " + "workflow. Each entry should be in 'interface.step' " +@@ -112,7 +112,7 @@ opts = [ + "Applies to user-requested steps, deploy template " + "steps, and driver steps alike.")), + cfg.ListOpt('disallow_service_steps', +- default=[], ++ default=['vendor.send_raw'], + mutable=True, + help=_("List of steps not allowed across the service " + "workflow. Each entry should be in 'interface.step' " +@@ -121,7 +121,7 @@ opts = [ + "Applies to user-requested steps and driver steps " + "alike.")), + cfg.ListOpt('disallow_clean_steps', +- default=[], ++ default=['vendor.send_raw'], + mutable=True, + help=_("List of steps not allowed across the clean " + "workflow. Each entry should be in 'interface.step' " +diff --git a/ironic/tests/unit/conductor/test_manager.py b/ironic/tests/unit/conductor/test_manager.py +index d4b28f477..5584b8e5e 100644 +--- a/ironic/tests/unit/conductor/test_manager.py ++++ b/ironic/tests/unit/conductor/test_manager.py +@@ -2174,6 +2174,30 @@ class ServiceDoNodeDeployTestCase(mgr_utils.ServiceSetUpMixin, + node.refresh() + self.assertEqual(states.AVAILABLE, node.provision_state) + ++ @mock.patch.object(deployments, 'start_deploy', autospec=True) ++ def test_do_node_deploy_disallowed_step_raises_send_raw( ++ self, mock_start, mock_iwdi): ++ mock_iwdi.return_value = False ++ self._start_service() ++ deploy_steps = [ ++ {'step': 'send_raw', ++ 'priority': 7, ++ 'interface': 'vendor'} ++ ] ++ node = obj_utils.create_test_node( ++ self.context, driver='fake-hardware', ++ provision_state=states.AVAILABLE, ++ target_provision_state=states.NOSTATE) ++ exc = self.assertRaises(messaging.rpc.ExpectedException, ++ self.service.do_node_deploy, ++ self.context, node.uuid, ++ deploy_steps=deploy_steps) ++ self.assertEqual(exception.StepNotAllowed, exc.exc_info[0]) ++ # start_deploy must NOT have been called ++ self.assertFalse(mock_start.called) ++ node.refresh() ++ self.assertEqual(states.AVAILABLE, node.provision_state) ++ + @mock.patch.object(deployments, 'start_deploy', autospec=True) + def test_do_node_deploy_allowed_step_proceeds(self, mock_start, + mock_iwdi): +@@ -3290,6 +3314,36 @@ class DoNodeCleanTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase): + # Node stays in original state + self.assertEqual(states.MANAGEABLE, node.provision_state) + ++ @mock.patch('ironic.conductor.task_manager.TaskManager.process_event', ++ autospec=True) ++ @mock.patch('ironic.drivers.modules.network.flat.FlatNetwork.validate', ++ autospec=True) ++ @mock.patch('ironic.drivers.modules.fake.FakePower.validate', ++ autospec=True) ++ def test_do_node_clean_disallowed_step_raises_send_raw( ++ self, mock_power_valid, ++ mock_network_valid, ++ mock_process): ++ node = obj_utils.create_test_node( ++ self.context, driver='fake-hardware', ++ provision_state=states.MANAGEABLE, ++ target_provision_state=states.NOSTATE) ++ self._start_service() ++ clean_steps = [ ++ {'step': 'send_raw', ++ 'priority': 7, ++ 'interface': 'vendor'} ++ ] ++ exc = self.assertRaises(messaging.rpc.ExpectedException, ++ self.service.do_node_clean, ++ self.context, node.uuid, clean_steps) ++ self.assertEqual(exception.StepNotAllowed, exc.exc_info[0]) ++ # process_event must NOT have been called ++ self.assertFalse(mock_process.called) ++ node.refresh() ++ # Node stays in original state ++ self.assertEqual(states.MANAGEABLE, node.provision_state) ++ + @mock.patch('ironic.conductor.task_manager.TaskManager.process_event', + autospec=True) + @mock.patch('ironic.drivers.modules.network.flat.FlatNetwork.validate', +@@ -3509,7 +3563,10 @@ class DoNodeServiceTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase): + target_provision_state=states.NOSTATE) + self._start_service() + self.service.do_node_service(self.context, +- node.uuid, {'foo': 'bar'}) ++ node.uuid, ++ [{'step': 'foo', ++ 'priority': 7, ++ 'interface': 'management'}]) + self.assertTrue(mock_pv.called) + self.assertTrue(mock_nv.called) + mock_event.assert_called_once_with( +@@ -3517,7 +3574,8 @@ class DoNodeServiceTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase): + 'service', + callback=mock.ANY, + call_args=(servicing.do_node_service, mock.ANY, +- {'foo': 'bar'}, False), ++ [{'step': 'foo', 'priority': 7, ++ 'interface': 'management'}], False), + err_handler=mock.ANY, target_state='active') + + @mock.patch('ironic.conductor.manager.ConductorManager._spawn_worker', +@@ -3565,6 +3623,33 @@ class DoNodeServiceTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase): + node.refresh() + self.assertEqual(states.ACTIVE, node.provision_state) + ++ @mock.patch('ironic.conductor.task_manager.TaskManager.process_event', ++ autospec=True) ++ @mock.patch('ironic.drivers.modules.network.flat.FlatNetwork.validate', ++ autospec=True) ++ @mock.patch('ironic.drivers.modules.fake.FakePower.validate', ++ autospec=True) ++ def test_do_node_service_disallowed_step_raises_on_send_raw( ++ self, mock_pv, mock_nv, ++ mock_event): ++ node = obj_utils.create_test_node( ++ self.context, driver='fake-hardware', ++ provision_state=states.ACTIVE, ++ target_provision_state=states.NOSTATE) ++ self._start_service() ++ service_steps = [ ++ {'step': 'send_raw', ++ 'priority': 7, ++ 'interface': 'vendor'} ++ ] ++ exc = self.assertRaises(messaging.rpc.ExpectedException, ++ self.service.do_node_service, ++ self.context, node.uuid, service_steps) ++ self.assertEqual(exception.StepNotAllowed, exc.exc_info[0]) ++ self.assertFalse(mock_event.called) ++ node.refresh() ++ self.assertEqual(states.ACTIVE, node.provision_state) ++ + @mock.patch('ironic.conductor.task_manager.TaskManager.process_event', + autospec=True) + @mock.patch('ironic.drivers.modules.network.flat.FlatNetwork.validate', +diff --git a/releasenotes/notes/disable_send_raw-f92217bf07c3eb38.yaml b/releasenotes/notes/disable_send_raw-f92217bf07c3eb38.yaml +new file mode 100644 +index 000000000..654bcab0a +--- /dev/null ++++ b/releasenotes/notes/disable_send_raw-f92217bf07c3eb38.yaml +@@ -0,0 +1,20 @@ ++--- ++security: ++ - | ++ The IPMI Vendor-Passthru interface method ``send_raw`` step has been ++ disabled by default due to security implications. A malicious user with ++ sufficient Ironic access could utilzie this interface to make manual ++ changes to BMCs when the ``ipmitool`` vendor-passthru interface was ++ enabled. ++fixes: ++ - | ++ Fixes a bug where ironic allowed the ability for operators to send ++ raw commands when the ``ipmitool`` vendor interface was enabled ++ and configured for a baremetal node utilizing the IPMI protocol. ++ ++ The Ironic project generally recommends the use of ``redfish`` ++ instead of IPMI, however recognizes that is not universally possible ++ for all operators. ++ ++ More information can be found in `bug 2150458 ++ `_. +-- +2.53.0 + diff -Nru ironic-29.0.5/debian/patches/OSSN-0106_Add_api_enable_ramdisk_endpoints_config_option.patch ironic-29.0.5/debian/patches/OSSN-0106_Add_api_enable_ramdisk_endpoints_config_option.patch --- ironic-29.0.5/debian/patches/OSSN-0106_Add_api_enable_ramdisk_endpoints_config_option.patch 1970-01-01 00:00:00.000000000 +0000 +++ ironic-29.0.5/debian/patches/OSSN-0106_Add_api_enable_ramdisk_endpoints_config_option.patch 2026-06-16 10:46:11.000000000 +0000 @@ -0,0 +1,188 @@ +Description: Add [api] enable_ramdisk_endpoints config option + Adds a configuration option to disable the unauthenticated ramdisk + agent API endpoints (lookup, heartbeat, continue_inspection) on a + per-API-instance basis. This enables split-horizon deployments + where a public-facing API cluster rejects agent traffic with + HTTP 403 while a backend API cluster on the provisioning network + continues to serve it. + . + The option is mutable and can be reloaded via SIGHUP. It works + regardless of auth_strategy, unlike the existing policy-based + approach which is only effective with Keystone authentication. + . + The security documentation has been updated to recommend this + option as the primary mechanism for disabling ramdisk endpoints, + with the policy approach documented as an alternative for + Keystone deployments along with a warning about its limitations. +Author: Julia Kreger +Date: Wed, 05 Aug 2026 12:42:00 -0700 +Bug: https://launchpad.net/bugs/2162821 +Bug-Debian: https://bugs.debian.org/1144214 +Assisted-By: Claude Opus 4.6 +Change-Id: Id53bea78d1d98faa4c3a036e5db4462afe3bcb5b +Signed-off-by: Julia Kreger +Origin: upstream, https://review.opendev.org/c/openstack/ironic/+/999897 +Last-Update: 2026-08-12 + +Index: ironic/doc/source/install/refarch/common.rst +=================================================================== +--- ironic.orig/doc/source/install/refarch/common.rst ++++ ironic/doc/source/install/refarch/common.rst +@@ -169,13 +169,18 @@ networks. + to the provisioning/cleaning/rescuing network. + + .. note:: +- Only two endpoints need to be exposed there:: ++ Only these endpoints need to be exposed there:: + + GET /v1/lookup + POST /v1/heartbeat/[a-z0-9\-]+ ++ POST /v1/continue_inspection + +- You may want to limit access from this network to only these endpoints, +- and make these endpoint not accessible from other networks. ++ You may want to limit access from this network to only these ++ endpoints. If you run separate API instances for public and ++ provisioning traffic (a split-horizon deployment), set ++ ``[api] enable_ramdisk_endpoints = False`` on the ++ public-facing instances to disable these endpoints there. ++ See :doc:`/admin/security` for details. + + * If the ``pxe`` boot interface (or any boot interface based on it) is used, + then the baremetal nodes should have untagged (access mode) connectivity +Index: ironic/ironic/api/controllers/v1/ramdisk.py +=================================================================== +--- ironic.orig/ironic/api/controllers/v1/ramdisk.py ++++ ironic/ironic/api/controllers/v1/ramdisk.py +@@ -96,7 +96,12 @@ class LookupController(rest.RestControll + state is not allowed for the lookup. + :raises: IncompleteLookup if neither node UUID nor any valid MAC + address was provided. ++ :raises: HTTPForbidden if ramdisk endpoints are disabled via ++ configuration. + """ ++ if not CONF.api.enable_ramdisk_endpoints: ++ raise exception.HTTPForbidden( ++ resource=api.request.path) + if not api_utils.allow_ramdisk_endpoints(): + raise exception.NotFound() + +@@ -193,7 +198,12 @@ class HeartbeatController(rest.RestContr + :raises: NoValidHost if RPC topic for node could not be retrieved. + :raises: NotFound if requested API version does not allow this + endpoint. ++ :raises: HTTPForbidden if ramdisk endpoints are disabled via ++ configuration. + """ ++ if not CONF.api.enable_ramdisk_endpoints: ++ raise exception.HTTPForbidden( ++ resource=api.request.path) + if not api_utils.allow_ramdisk_endpoints(): + raise exception.NotFound() + +Index: ironic/ironic/common/policy.py +=================================================================== +--- ironic.orig/ironic/common/policy.py ++++ ironic/ironic/common/policy.py +@@ -1445,7 +1445,7 @@ utility_policies = [ + policy.DocumentedRuleDefault( + name='baremetal:driver:ipa_lookup', + check_str='', +- description='Access IPA ramdisk functions', ++ description='Access IPA ramdisk lookup endpoint', + operations=[{'path': '/lookup', 'method': 'GET'}], + deprecated_rule=deprecated_ipa_lookup + ), +Index: ironic/ironic/conf/api.py +=================================================================== +--- ironic.orig/ironic/conf/api.py ++++ ironic/ironic/conf/api.py +@@ -73,6 +73,17 @@ opts = [ + "[oslo_middleware]enable_proxy_headers_parsing " + "option or configure [api]public_endpoint option " + "to set URLs in responses to the SSL terminated one.")), ++ cfg.BoolOpt('enable_ramdisk_endpoints', ++ default=True, ++ mutable=True, ++ help=_('Whether to enable the ramdisk agent API ' ++ 'endpoints (lookup, heartbeat, and ' ++ 'continue_inspection). Set this to False on ' ++ 'API instances that should not accept requests ' ++ 'from ramdisk agents, for example the ' ++ 'public-facing API in a split-horizon ' ++ 'deployment. When disabled, these endpoints ' ++ 'return HTTP 403.')), + cfg.BoolOpt('restrict_lookup', + default=True, + mutable=True, +Index: ironic/ironic/tests/unit/api/controllers/v1/test_ramdisk.py +=================================================================== +--- ironic.orig/ironic/tests/unit/api/controllers/v1/test_ramdisk.py ++++ ironic/ironic/tests/unit/api/controllers/v1/test_ramdisk.py +@@ -87,6 +87,15 @@ class TestLookup(test_api_base.BaseApiTe + self.assertIsNotNone(data['config']['agent_token']) + self.assertNotEqual('******', data['config']['agent_token']) + ++ def test_disabled_by_config(self): ++ CONF.set_override('enable_ramdisk_endpoints', False, 'api') ++ response = self.get_json( ++ '/lookup?node_uuid=%s' % self.node.uuid, ++ headers={api_base.Version.string: str( ++ api_v1.max_version())}, ++ expect_errors=True) ++ self.assertEqual(http_client.FORBIDDEN, response.status_int) ++ + def test_nothing_provided(self): + response = self.get_json( + '/lookup', +@@ -201,6 +210,18 @@ class TestLookup(test_api_base.BaseApiTe + @mock.patch.object(rpcapi.ConductorAPI, 'get_topic_for', + lambda *n: 'test-topic') + class TestHeartbeat(test_api_base.BaseApiTest): ++ def test_disabled_by_config(self): ++ node = obj_utils.create_test_node(self.context) ++ CONF.set_override('enable_ramdisk_endpoints', False, 'api') ++ response = self.post_json( ++ '/heartbeat/%s' % node.uuid, ++ {'callback_url': 'https://url', ++ 'agent_token': 'x'}, ++ headers={api_base.Version.string: str( ++ api_v1.max_version())}, ++ expect_errors=True) ++ self.assertEqual(http_client.FORBIDDEN, response.status_int) ++ + def test_old_api_version(self): + response = self.post_json( + '/heartbeat/%s' % uuidutils.generate_uuid(), +Index: ironic/releasenotes/notes/enable-ramdisk-endpoints-config-a1b2c3d4e5f6a7b8.yaml +=================================================================== +--- /dev/null ++++ ironic/releasenotes/notes/enable-ramdisk-endpoints-config-a1b2c3d4e5f6a7b8.yaml +@@ -0,0 +1,27 @@ ++--- ++features: ++ - | ++ Adds a new ``[api] enable_ramdisk_endpoints`` configuration option ++ (default ``True``) that allows operators to disable the ++ ``/v1/lookup``, ``/v1/heartbeat``, and ``/v1/continue_inspection`` ++ endpoints on a per-API-instance basis. When set to ``False``, these ++ endpoints return HTTP 403. This is intended for split-horizon ++ deployments where a public-facing API cluster should not accept ++ requests from ramdisk agents, while a separate backend API cluster ++ on the provisioning network serves agent traffic. The option is ++ mutable and can be reloaded via SIGHUP. ++ See `bug 2162821 ++ `_ for details. ++security: ++ - | ++ Operators can now disable the unauthenticated ramdisk agent ++ endpoints (``/v1/lookup``, ``/v1/heartbeat``, ++ ``/v1/continue_inspection``) by setting ++ ``[api] enable_ramdisk_endpoints = False``. This mitigates the ++ risk described in `bug 2162821 ++ `_ where an ++ attacker on the provisioning network could call ``/v1/lookup`` ++ to obtain an agent token and impersonate heartbeats. In a ++ split-horizon deployment, disabling these endpoints on the ++ public API ensures that agent traffic is only accepted on the ++ trusted backend network. diff -Nru ironic-29.0.5/debian/patches/series ironic-29.0.5/debian/patches/series --- ironic-29.0.5/debian/patches/series 2026-06-01 07:59:53.000000000 +0000 +++ ironic-29.0.5/debian/patches/series 2026-06-16 10:46:11.000000000 +0000 @@ -6,3 +6,10 @@ CVE-2026-44917_disable-driver_info-level-pxe_template-override.patch CVE-2026-46447_Sanitize-kernel_append_parms.patch CVE-2026-48681-directory_transversal_ISO9660_support.patch +CVE-2026-46447_erata1_Fix_kernel_parameter_parsing_for_quoted_values_and_whitespace.patch +CVE-2026-54421_Fix_sensitive_properties_returned_on_volume_targets.patch +CVE-2026-43003_Add_an_agent_flag_to_disable_installing_boatloaders.patch +CVE-2026-44918-Prevent_rehoming_resources_to_nodes_with_different_owner.patch +CVE-2026-54423-Add_operator_configurable_step_disallow_lists.patch +CVE-2026-54423-block_vendor.send_raw.patch +OSSN-0106_Add_api_enable_ramdisk_endpoints_config_option.patch