Skip to content

Commit 8010823

Browse files
authored
Merge pull request #99 from stackhpc/upstream/2023.1-2026-08-10
Synchronise 2023.1 with upstream
2 parents a912833 + f704efe commit 8010823

4 files changed

Lines changed: 164 additions & 5 deletions

File tree

‎ironic_python_agent/tests/unit/extensions/test_standby.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1471,8 +1471,8 @@ def test__sync_clock(self, execute_mock, mock_timemethod):
14711471
self.agent_extension._sync_clock()
14721472

14731473
calls = [mock.call('chronyc', 'shutdown', check_exit_code=[0, 1]),
1474-
mock.call("chronyd -q 'server 192.168.1.1 iburst'",
1475-
shell=True),
1474+
mock.call('chronyd', '-q',
1475+
'server 192.168.1.1 iburst'),
14761476
mock.call('hwclock', '-v', '--systohc')]
14771477
execute_mock.assert_has_calls(calls)
14781478

‎ironic_python_agent/tests/unit/test_utils.py‎

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -846,7 +846,7 @@ def test_sync_clock_chrony(self, mock_time_method, mock_execute):
846846
utils.sync_clock()
847847
mock_execute.assert_has_calls([
848848
mock.call('chronyc', 'shutdown', check_exit_code=[0, 1]),
849-
mock.call("chronyd -q 'server 192.168.1.1 iburst'", shell=True),
849+
mock.call('chronyd', '-q', 'server 192.168.1.1 iburst'),
850850
])
851851

852852
@mock.patch.object(utils, 'determine_time_method', autospec=True)
@@ -877,6 +877,126 @@ def test_sync_clock_ntp_server_is_none(self, mock_time_method,
877877
utils.sync_clock()
878878
self.assertEqual(0, mock_execute.call_count)
879879

880+
def test_sync_clock_invalid_ntp_server_shell_escape(
881+
self, mock_execute):
882+
self.config(ntp_server="'; rm -rf /; echo '")
883+
self.assertRaisesRegex(
884+
errors.CommandExecutionError,
885+
'Invalid NTP server address',
886+
utils.sync_clock)
887+
mock_execute.assert_not_called()
888+
889+
def test_sync_clock_invalid_ntp_server_command_sub(
890+
self, mock_execute):
891+
self.config(ntp_server='$(reboot)')
892+
self.assertRaisesRegex(
893+
errors.CommandExecutionError,
894+
'Invalid NTP server address',
895+
utils.sync_clock)
896+
mock_execute.assert_not_called()
897+
898+
def test_sync_clock_invalid_ntp_server_backtick(
899+
self, mock_execute):
900+
self.config(ntp_server='`reboot`')
901+
self.assertRaisesRegex(
902+
errors.CommandExecutionError,
903+
'Invalid NTP server address',
904+
utils.sync_clock)
905+
mock_execute.assert_not_called()
906+
907+
def test_sync_clock_invalid_ntp_server_pipe(
908+
self, mock_execute):
909+
self.config(ntp_server='foo | bar')
910+
self.assertRaisesRegex(
911+
errors.CommandExecutionError,
912+
'Invalid NTP server address',
913+
utils.sync_clock)
914+
mock_execute.assert_not_called()
915+
916+
def test_sync_clock_invalid_ntp_server_chain(
917+
self, mock_execute):
918+
self.config(ntp_server='foo && bar')
919+
self.assertRaisesRegex(
920+
errors.CommandExecutionError,
921+
'Invalid NTP server address',
922+
utils.sync_clock)
923+
mock_execute.assert_not_called()
924+
925+
def test_sync_clock_invalid_ntp_server_space(
926+
self, mock_execute):
927+
self.config(ntp_server='foo bar')
928+
self.assertRaisesRegex(
929+
errors.CommandExecutionError,
930+
'Invalid NTP server address',
931+
utils.sync_clock)
932+
mock_execute.assert_not_called()
933+
934+
@mock.patch.object(utils, 'determine_time_method', autospec=True)
935+
def test_sync_clock_valid_ipv6(self, mock_time_method,
936+
mock_execute):
937+
self.config(ntp_server='2001:db8::1')
938+
mock_time_method.return_value = 'ntpdate'
939+
utils.sync_clock()
940+
mock_execute.assert_has_calls(
941+
[mock.call('ntpdate', '2001:db8::1')])
942+
943+
@mock.patch.object(utils, 'determine_time_method', autospec=True)
944+
def test_sync_clock_valid_hostname(self, mock_time_method,
945+
mock_execute):
946+
self.config(ntp_server='ntp.example.com')
947+
mock_time_method.return_value = 'ntpdate'
948+
utils.sync_clock()
949+
mock_execute.assert_has_calls(
950+
[mock.call('ntpdate', 'ntp.example.com')])
951+
952+
@mock.patch.object(utils, 'determine_time_method', autospec=True)
953+
def test_sync_clock_valid_hostname_with_hyphens(
954+
self, mock_time_method, mock_execute):
955+
self.config(ntp_server='my-ntp_server.example.com')
956+
mock_time_method.return_value = 'ntpdate'
957+
utils.sync_clock()
958+
mock_execute.assert_has_calls(
959+
[mock.call('ntpdate',
960+
'my-ntp_server.example.com')])
961+
962+
def test_validate_ntp_server_valid_ipv4(self, mock_execute):
963+
utils._validate_ntp_server('192.168.1.1')
964+
965+
def test_validate_ntp_server_valid_ipv6(self, mock_execute):
966+
utils._validate_ntp_server('2001:db8::1')
967+
968+
def test_validate_ntp_server_valid_hostname(self,
969+
mock_execute):
970+
utils._validate_ntp_server('ntp.example.com')
971+
972+
def test_validate_ntp_server_rejects_semicolon(
973+
self, mock_execute):
974+
self.assertRaises(
975+
errors.CommandExecutionError,
976+
utils._validate_ntp_server,
977+
"'; rm -rf /; echo '")
978+
979+
def test_validate_ntp_server_rejects_dollar(self,
980+
mock_execute):
981+
self.assertRaises(
982+
errors.CommandExecutionError,
983+
utils._validate_ntp_server,
984+
'$(reboot)')
985+
986+
def test_validate_ntp_server_rejects_backtick(
987+
self, mock_execute):
988+
self.assertRaises(
989+
errors.CommandExecutionError,
990+
utils._validate_ntp_server,
991+
'`reboot`')
992+
993+
def test_validate_ntp_server_rejects_space(self,
994+
mock_execute):
995+
self.assertRaises(
996+
errors.CommandExecutionError,
997+
utils._validate_ntp_server,
998+
'foo bar')
999+
8801000

8811001
@mock.patch.object(utils, '_unmount_any_config_drives', autospec=True)
8821002
@mock.patch.object(utils, '_booted_from_vmedia', autospec=True)

‎ironic_python_agent/utils.py‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@
1919
import errno
2020
import glob
2121
import io
22+
import ipaddress
2223
import json
2324
import os
2425
import re
26+
import shlex
2527
import shutil
2628
import subprocess
2729
import sys
@@ -760,6 +762,7 @@ def get_partition_table_type_from_specs(node):
760762

761763

762764
_LARGE_KEYS = frozenset(['configdrive', 'system_logs'])
765+
_VALID_NTP_SERVER_RE = re.compile(r'^[a-zA-Z0-9.:_-]+$')
763766

764767

765768
def remove_large_keys(var):
@@ -794,6 +797,27 @@ def determine_time_method():
794797
return None
795798

796799

800+
def _validate_ntp_server(ntp_server):
801+
"""Validate an NTP server address for safety.
802+
803+
:param ntp_server: The NTP server address string.
804+
:raises: CommandExecutionError if the value is not valid.
805+
"""
806+
try:
807+
ipaddress.ip_address(ntp_server)
808+
return
809+
except ValueError:
810+
pass
811+
812+
if (ntp_server
813+
and len(ntp_server) <= 253
814+
and _VALID_NTP_SERVER_RE.match(ntp_server)):
815+
return
816+
817+
raise errors.CommandExecutionError(
818+
'Invalid NTP server address: %s' % ntp_server)
819+
820+
797821
def sync_clock(ignore_errors=False):
798822
"""Syncs the software clock of the system.
799823
@@ -817,6 +841,8 @@ def sync_clock(ignore_errors=False):
817841
if not CONF.ntp_server:
818842
return
819843

844+
_validate_ntp_server(CONF.ntp_server)
845+
820846
method = determine_time_method()
821847

822848
if method == 'ntpdate':
@@ -834,8 +860,9 @@ def sync_clock(ignore_errors=False):
834860
# stop chronyd, ignore if it ran before or not
835861
execute('chronyc', 'shutdown', check_exit_code=[0, 1])
836862
# force a time sync now
837-
query = "server " + CONF.ntp_server + " iburst"
838-
execute("chronyd -q \'%s\'" % query, shell=True)
863+
query = ("server %s iburst"
864+
% shlex.quote(CONF.ntp_server))
865+
execute('chronyd', '-q', query)
839866
LOG.debug('Set software clock using chrony')
840867
except (processutils.ProcessExecutionError,
841868
errors.CommandExecutionError) as e:
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
security:
3+
- |
4+
Fixes a shell command injection vulnerability in the NTP clock
5+
synchronization when using chrony. The ``ntp_server`` configuration
6+
value was interpolated into a shell command string, allowing
7+
a crafted value to execute arbitrary system commands. The chrony
8+
execution path no longer uses a shell, and the ``ntp_server``
9+
value is now validated and sanitized before use.
10+
See `bug 2160050
11+
<https://bugs.launchpad.net/ironic-python-agent/+bug/2160050>`_
12+
for details.

0 commit comments

Comments
 (0)