From 027cbef223616f5ab6c73e60bcaa9f9e81a6ce67 Mon Sep 17 00:00:00 2001 From: Daniel Mach Date: Wed, 28 Jun 2023 16:39:42 +0200 Subject: [PATCH] Fix utf8 handling in 'pass' renderer and make it more robust (#579) * Migrate string formatting in 'pass' renderer to a f-string * Fix utf8 handling in 'pass' renderer and make it more robust --- changelog/64300.fixed.md | 1 + salt/renderers/pass.py | 12 +-- tests/pytests/unit/renderers/test_pass.py | 99 +++++++++++++++++++++++ 3 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 changelog/64300.fixed.md diff --git a/changelog/64300.fixed.md b/changelog/64300.fixed.md new file mode 100644 index 0000000000..4418db1d04 --- /dev/null +++ b/changelog/64300.fixed.md @@ -0,0 +1 @@ +Fix utf8 handling in 'pass' renderer diff --git a/salt/renderers/pass.py b/salt/renderers/pass.py index ba0f152c23..ae75bba443 100644 --- a/salt/renderers/pass.py +++ b/salt/renderers/pass.py @@ -145,23 +145,17 @@ def _fetch_secret(pass_path): env["GNUPGHOME"] = pass_gnupghome try: - proc = Popen(cmd, stdout=PIPE, stderr=PIPE, env=env) + proc = Popen(cmd, stdout=PIPE, stderr=PIPE, env=env, encoding="utf-8") pass_data, pass_error = proc.communicate() pass_returncode = proc.returncode - except OSError as e: + except (OSError, UnicodeDecodeError) as e: pass_data, pass_error = "", str(e) pass_returncode = 1 # The version of pass used during development sent output to # stdout instead of stderr even though its returncode was non zero. if pass_returncode or not pass_data: - try: - pass_error = pass_error.decode("utf-8") - except (AttributeError, ValueError): - pass - msg = "Could not fetch secret '{}' from the password store: {}".format( - pass_path, pass_error - ) + msg = f"Could not fetch secret '{pass_path}' from the password store: {pass_error}" if pass_strict_fetch: raise SaltRenderError(msg) else: diff --git a/tests/pytests/unit/renderers/test_pass.py b/tests/pytests/unit/renderers/test_pass.py index 1e2ebb7ea8..f7c79e1fe1 100644 --- a/tests/pytests/unit/renderers/test_pass.py +++ b/tests/pytests/unit/renderers/test_pass.py @@ -1,8 +1,12 @@ import importlib +import os +import shutil +import tempfile import pytest import salt.exceptions +import salt.utils.files from tests.support.mock import MagicMock, patch # "pass" is a reserved keyword, we need to import it differently @@ -19,6 +23,47 @@ def configure_loader_modules(master_opts): } +@pytest.fixture() +def pass_executable(request): + tmp_dir = tempfile.mkdtemp(prefix="salt_pass_") + pass_path = os.path.join(tmp_dir, "pass") + with salt.utils.files.fopen(pass_path, "w") as f: + f.write("#!/bin/sh\n") + # return path path wrapped into unicode characters + # pass args ($1, $2) are ("show", ) + f.write('echo "α>>> $2 <<<β"\n') + os.chmod(pass_path, 0o755) + yield pass_path + shutil.rmtree(tmp_dir) + + +@pytest.fixture() +def pass_executable_error(request): + tmp_dir = tempfile.mkdtemp(prefix="salt_pass_") + pass_path = os.path.join(tmp_dir, "pass") + with salt.utils.files.fopen(pass_path, "w") as f: + f.write("#!/bin/sh\n") + # return error message with unicode characters + f.write('echo "ERROR: αβγ" >&2\n') + f.write("exit 1\n") + os.chmod(pass_path, 0o755) + yield pass_path + shutil.rmtree(tmp_dir) + + +@pytest.fixture() +def pass_executable_invalid_utf8(request): + tmp_dir = tempfile.mkdtemp(prefix="salt_pass_") + pass_path = os.path.join(tmp_dir, "pass") + with salt.utils.files.fopen(pass_path, "wb") as f: + f.write(b"#!/bin/sh\n") + # return invalid utf-8 sequence + f.write(b'echo "\x80\x81"\n') + os.chmod(pass_path, 0o755) + yield pass_path + shutil.rmtree(tmp_dir) + + # The default behavior is that if fetching a secret from pass fails, # the value is passed through. Even the trailing newlines are preserved. def test_passthrough(): @@ -161,3 +206,57 @@ def test_env(): call_args, call_kwargs = popen_mock.call_args_list[0] assert call_kwargs["env"]["GNUPGHOME"] == config["pass_gnupghome"] assert call_kwargs["env"]["PASSWORD_STORE_DIR"] == config["pass_dir"] + + +@pytest.mark.skip_on_windows(reason="Not supported on Windows") +def test_utf8(pass_executable): + config = { + "pass_variable_prefix": "pass:", + "pass_strict_fetch": True, + } + mocks = { + "_get_pass_exec": MagicMock(return_value=pass_executable), + } + + pass_path = "pass:secret" + with patch.dict(pass_.__opts__, config), patch.dict(pass_.__dict__, mocks): + result = pass_.render(pass_path) + assert result == "α>>> secret <<<β" + + +@pytest.mark.skip_on_windows(reason="Not supported on Windows") +def test_utf8_error(pass_executable_error): + config = { + "pass_variable_prefix": "pass:", + "pass_strict_fetch": True, + } + mocks = { + "_get_pass_exec": MagicMock(return_value=pass_executable_error), + } + + pass_path = "pass:secret" + with patch.dict(pass_.__opts__, config), patch.dict(pass_.__dict__, mocks): + with pytest.raises( + salt.exceptions.SaltRenderError, + match=r"Could not fetch secret 'secret' from the password store: ERROR: αβγ", + ): + result = pass_.render(pass_path) + + +@pytest.mark.skip_on_windows(reason="Not supported on Windows") +def test_invalid_utf8(pass_executable_invalid_utf8): + config = { + "pass_variable_prefix": "pass:", + "pass_strict_fetch": True, + } + mocks = { + "_get_pass_exec": MagicMock(return_value=pass_executable_invalid_utf8), + } + + pass_path = "pass:secret" + with patch.dict(pass_.__opts__, config), patch.dict(pass_.__dict__, mocks): + with pytest.raises( + salt.exceptions.SaltRenderError, + match=r"Could not fetch secret 'secret' from the password store: 'utf-8' codec can't decode byte 0x80 in position 0: invalid start byte", + ): + result = pass_.render(pass_path) -- 2.41.0