Skip to content

Commit f22368b

Browse files
committed
fix(shell)!: run commands as argv lists, closing shell injection (GHSA-798h-hpph-m24j)
shell_exec() now requires the command as a list of arguments and always runs with shell=False, so argument values are never interpreted by a shell. It no longer accepts a command string, a shell= parameter or | pipelines, and get_command_output() has been removed. - shell: list-only argv, no shell, no shlex, no pipe split; raises TypeError on a string. Add safe_cli_value() to reject option-like values (leading "-") for positional or target arguments (ssh destination, ping target, ...). - ssh: build_options()/target() return argv tokens; run()/scp()/rsync() build argv lists, drop use_shell, and reject an option-like host or username. - distro, version: read /etc/os-release directly instead of sourcing via a shell. - base: oao() and cu() normalize CRLF/CR to LF so Windows output is not rendered with doubled line breaks in pre-wrap web UIs. - shell: decode Windows pipe output with the console/OEM code page instead of UTF-8, fixing UnicodeDecodeError and mangled umlauts (Linuxfabrik/monitoring-plugins#681). BREAKING CHANGE: lib.shell.shell_exec() requires an argv list and drops the shell= parameter, the | pipeline split and get_command_output(); lib.ssh helpers return and accept argv lists and drop use_shell.
1 parent 4d85edb commit f22368b

17 files changed

Lines changed: 536 additions & 622 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88

99
## [Unreleased]
1010

11+
### Changed
12+
13+
* distro.py, version.py: read the OS name and version directly from `/etc/os-release` instead of sourcing it through a shell
14+
* shell.py: `shell_exec()` requires the command as a list of arguments (argv) and always runs with `shell=False`. It no longer accepts a command string, a `shell=` parameter, or `|` pipelines. This is a breaking change: pass `['df', '-h', path]` instead of `'df -h ' + path`
15+
* shell.py: new `safe_cli_value()` rejects a value that a called program could misread as an option (leading `-`), to guard positional or target arguments (such as an ssh destination or a `ping` target) against option injection
16+
* ssh.py: `build_options()` and `target()` return argument lists/tokens instead of pre-quoted strings; `run()`, `scp()` and `rsync()` build argument lists, drop the `use_shell` parameter, and reject an option-like host or username
17+
18+
### Removed
19+
20+
* shell.py: removed `get_command_output()` (it had no consumers; use `shell_exec()` directly)
21+
1122
### Fixed
1223

24+
* base.py: `oao()` normalizes CRLF and stray CR in the plugin message to LF, so Windows command output is no longer rendered with doubled line breaks in web UIs that use `white-space: pre-wrap`
1325
* endoflifedate.py: the Apache httpd and Rocket.Chat offline data is keyed under their current endoflife.date URLs (`apache-http-server`, `rocket-chat`), so version checks still work when the endoflife.date API is unreachable
1426
* lftest.py: use the classic `with a, b:` form instead of parenthesized context managers (Python 3.10+ syntax), so the module parses under RHEL 8's default Python 3.6
27+
* shell.py: on Windows, a subprocess's piped output is decoded with the console / OEM code page instead of UTF-8, so non-ASCII characters such as umlauts in usernames are no longer mangled ([monitoring-plugins#681](https://github.com/Linuxfabrik/monitoring-plugins/issues/681))
1528
* url.py: use the classic `with a, b:` form instead of parenthesized context managers (Python 3.10+ syntax), so `import lib.url` no longer raises a SyntaxError under RHEL 8's default Python 3.6
1629

1730

base.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"""Provides very common every-day functions."""
1212

1313
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
14-
__version__ = '2026051002'
14+
__version__ = '2026061201'
1515

1616
import html
1717
import numbers
@@ -119,6 +119,9 @@ def cu(msg=None):
119119
tb = format_exc() if has_traceback else None
120120

121121
if msg is not None:
122+
# Normalize line endings to LF (see oao); error output may also carry
123+
# CRLF, for example when a Windows command prints its error to stdout.
124+
msg = msg.replace('\r\n', '\n').replace('\r', '\n')
122125
msg = (
123126
txt.sanitize_sensitive_data(msg).strip().replace('<', "'").replace('>', "'")
124127
)
@@ -628,6 +631,10 @@ def oao(msg, state=STATE_OK, perfdata='', always_ok=False):
628631
(and exits with code 2)
629632
630633
"""
634+
# Normalize line endings to LF. Output captured on Windows (or read from a
635+
# file or HTTP response) can carry CRLF or stray CR, which a web UI showing
636+
# the output with `white-space: pre-wrap` would render as an extra line break.
637+
msg = msg.replace('\r\n', '\n').replace('\r', '\n')
631638
msg = html.escape(
632639
txt.sanitize_sensitive_data(msg.strip()),
633640
quote=False,

db_mysql.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
warnings.filterwarnings('ignore', category=UserWarning, module='pymysql')
2121

2222
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
23-
__version__ = '2026060201'
23+
__version__ = '2026061201'
2424

2525
try:
2626
import pymysql.cursors
@@ -302,7 +302,9 @@ def _align_session_collation(conn):
302302
# Whitelist identifiers to keep the formatted `SET NAMES` safe from
303303
# any caller-controlled value (the row comes from a system view, but
304304
# defence in depth doesn't cost anything).
305-
if not (re.match(r'^[A-Za-z0-9_]+$', cs) and re.match(r'^[A-Za-z0-9_]+$', coll)):
305+
if not (
306+
re.match(r'^[A-Za-z0-9_]+$', cs) and re.match(r'^[A-Za-z0-9_]+$', coll)
307+
):
306308
return
307309
with conn.cursor() as cursor:
308310
cursor.execute(f'set names {cs} collate {coll}')
@@ -541,10 +543,10 @@ def get_server_info(banner=None):
541543
from . import shell
542544

543545
for command in (
544-
'mysqld --version',
545-
'mariadbd --version',
546-
'mariadb --version',
547-
'mysql --version',
546+
['mysqld', '--version'],
547+
['mariadbd', '--version'],
548+
['mariadb', '--version'],
549+
['mysql', '--version'],
548550
):
549551
success, result = shell.shell_exec(command)
550552
if not success:

disk.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"""
1414

1515
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
16-
__version__ = '2026060801'
16+
__version__ = '2026061201'
1717

1818
import csv
1919
import os
@@ -747,7 +747,14 @@ def udevadm(device, _property):
747747
>>> udevadm('/dev/linuxfabrik', 'DEVNAME')
748748
''
749749
"""
750-
success, result = shell.shell_exec(f'udevadm info --query=property --name={device}')
750+
# Only query real device nodes under /dev. Resolving the path first defends
751+
# against a crafted `device` that points elsewhere or smuggles arguments.
752+
device = os.path.realpath(device)
753+
if not device.startswith('/dev/'):
754+
return ''
755+
success, result = shell.shell_exec(
756+
['udevadm', 'info', '--query=property', f'--name={device}']
757+
)
751758
if not success:
752759
return ''
753760
stdout, _, _ = result

distro.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,13 @@
1717
"""
1818

1919
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
20-
__version__ = '2025061001'
20+
__version__ = '2026061201'
2121

2222

2323
import os
2424
import platform
2525
import re
2626

27-
from . import shell
28-
2927
# --- Static mappings ---
3028

3129
OSDIST_LIST = (
@@ -430,15 +428,22 @@ def get_distribution_facts():
430428
distro = facts.get('distribution_file_variety', 'distribution')
431429
facts['os_family'] = _map_os_family(distro) # returns 'RedHat', for example
432430

433-
# hardcoded command, no user input; shell=True needed to source the file
434-
cmd = '. /etc/os-release && echo "$NAME $VERSION"'
435-
success, result = shell.shell_exec(cmd, shell=True) # nosec B604
436-
if not success:
431+
# read NAME and VERSION from /etc/os-release
432+
values = {}
433+
try:
434+
with open('/etc/os-release', encoding='utf-8') as os_release:
435+
for line in os_release:
436+
line = line.strip()
437+
if not line or line.startswith('#') or '=' not in line:
438+
continue
439+
key, value = line.split('=', 1)
440+
values[key] = value.strip().strip('"').strip("'")
441+
except OSError:
437442
return facts
438443

439-
stdout, _, _ = result
440-
facts['os_info'] = (
441-
stdout.strip()
442-
) # returns 'Fedora Linux 41 (Workstation Edition)', for example
444+
# returns 'Fedora Linux 41 (Workstation Edition)', for example
445+
facts['os_info'] = ' '.join(
446+
part for part in (values.get('NAME'), values.get('VERSION')) if part
447+
)
443448

444449
return facts

dmidecode.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"""
1515

1616
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
17-
__version__ = '2026060201'
17+
__version__ = '2026061201'
1818

1919
import re
2020

@@ -511,7 +511,7 @@ def get_data():
511511
...
512512
}
513513
"""
514-
success, result = shell.shell_exec('sudo dmidecode')
514+
success, result = shell.shell_exec(['sudo', 'dmidecode'])
515515
if not success:
516516
return False
517517

lftest.py

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@
1313
import contextlib
1414
import os
1515
import re
16+
import shlex
1617
import tempfile
1718

1819
from . import base, disk, shell
1920

2021
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
21-
__version__ = '2026060202'
22+
__version__ = '2026061201'
2223

2324

2425
def run(test_instance, plugin, testcase):
@@ -73,8 +74,10 @@ def run(test_instance, plugin, testcase):
7374
... with self.subTest(id=t['id']):
7475
... lib.lftest.run(self, self.check, t)
7576
"""
77+
# params come from the trusted testcase definition; tokenize them the way a
78+
# shell would so quoted multi-word values stay intact, then build the argv.
7679
params = testcase.get('params', '')
77-
cmd = f'{plugin} {params} --test={testcase["test"]}'.strip()
80+
cmd = [plugin, *shlex.split(params), f'--test={testcase["test"]}']
7881
stdout, stderr, retc = base.coe(shell.shell_exec(cmd))
7982

8083
test_instance.assertEqual(
@@ -133,7 +136,6 @@ def test(self):
133136
### Example
134137
>>> class TestCheck(unittest.TestCase):
135138
... check = '../my-plugin'
136-
...
137139
>>> attach_tests(TestCheck, TESTS)
138140
>>>
139141
>>> if __name__ == '__main__':
@@ -157,6 +159,7 @@ def test(self):
157159
def _make(captured_testcase):
158160
def _method(self):
159161
run(self, getattr(self, plugin_attr), captured_testcase)
162+
160163
return _method
161164

162165
setattr(test_class, method_name, _make(testcase))
@@ -222,6 +225,7 @@ def attach_each(test_class, items, action, id_func=str):
222225
def _make(captured_item):
223226
def _method(self):
224227
action(self, captured_item)
228+
225229
return _method
226230

227231
setattr(test_class, method_name, _make(item))
@@ -339,8 +343,7 @@ def run_container(
339343
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
340344
except ImportError as e:
341345
raise RuntimeError(
342-
'testcontainers is not installed; run '
343-
"`pip install testcontainers`"
346+
'testcontainers is not installed; run `pip install testcontainers`'
344347
) from e
345348

346349
c = DockerContainer(image)
@@ -456,12 +459,15 @@ def _run_mysql_compatible_resolved(image_ref, command, seed):
456459
# in favour of `mariadb`; sclorg c10s ships both; MySQL
457460
# ships `mysql` only. Prefer `mariadb`, fall back to
458461
# `mysql` so all flavours work.
459-
container.exec([
460-
'sh', '-c',
461-
'if command -v mariadb >/dev/null 2>&1; then CLIENT=mariadb; '
462-
'else CLIENT=mysql; fi; '
463-
f'"$CLIENT" -utest -ptest test -e "{seed}"',
464-
])
462+
container.exec(
463+
[
464+
'sh',
465+
'-c',
466+
'if command -v mariadb >/dev/null 2>&1; then CLIENT=mariadb; '
467+
'else CLIENT=mysql; fi; '
468+
f'"$CLIENT" -utest -ptest test -e "{seed}"',
469+
]
470+
)
465471

466472
host = container.get_container_host_ip()
467473
port = container.get_exposed_port(3306)
@@ -574,7 +580,8 @@ def run_mysql_compatible_from_containerfile(
574580
... ) as (container, defaults_file):
575581
... result = subprocess.run(
576582
... ['python3', '../mysql-traffic', f'--defaults-file={defaults_file}'],
577-
... capture_output=True, text=True,
583+
... capture_output=True,
584+
... text=True,
578585
... )
579586
"""
580587
try:
@@ -594,14 +601,19 @@ def run_mysql_compatible_from_containerfile(
594601

595602
# No parenthesized context managers: they are Python 3.10+ syntax and break
596603
# parsing on RHEL 8's default Python 3.6.
597-
with DockerImage(
598-
path=build_dir,
599-
dockerfile_path=dockerfile_name,
600-
tag=tag,
601-
clean_up=False,
602-
) as image, _run_mysql_compatible_resolved(
603-
str(image.tag), command, seed,
604-
) as result:
604+
with (
605+
DockerImage(
606+
path=build_dir,
607+
dockerfile_path=dockerfile_name,
608+
tag=tag,
609+
clean_up=False,
610+
) as image,
611+
_run_mysql_compatible_resolved(
612+
str(image.tag),
613+
command,
614+
seed,
615+
) as result,
616+
):
605617
yield result
606618

607619

nextcloud.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111
"""This library collects some Nextcloud related functions."""
1212

1313
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
14-
__version__ = '2026041401'
14+
__version__ = '2026061201'
1515

1616
import json
1717
import os
18+
import shlex
1819
import shutil
1920

2021
from . import disk, shell
@@ -80,16 +81,17 @@ def run_occ(path, cmd, _format='json'):
8081
# get the owner of config.php
8182
user = disk.get_owner(os.path.join(path, 'config/config.php'))
8283
occ = os.path.join(path, 'occ')
83-
# When running a command as a UID, many shells require
84-
# that the `#` be escaped with a backslash (`\`).
85-
sudo_cmd = f'sudo -u \\#{user} {php} {occ} {cmd}'
84+
# Run occ as the numeric UID of the config.php owner. `sudo -u '#<uid>'` selects
85+
# the user by UID; the `#` only needs escaping for a shell, which we do not use.
86+
sudo_cmd = ['sudo', '-u', f'#{user}', php, occ, *shlex.split(cmd)]
8687

8788
success, result = shell.shell_exec(sudo_cmd)
8889
stdout, stderr, rc = result
8990

9091
# Prefer the return code to decide success/failure, not stderr presence
9192
if not success or rc != 0:
92-
return False, f'Error running `{sudo_cmd}`: rc={rc}\n{stderr or stdout}'
93+
cmd_display = ' '.join(sudo_cmd)
94+
return False, f'Error running `{cmd_display}`: rc={rc}\n{stderr or stdout}'
9395

9496
# If we expect JSON, try to parse it; otherwise return text
9597
if str(_format).lower() == 'json':

0 commit comments

Comments
 (0)