From 6a57c051924d7f65fd4c6e9133d0f0e1ec13015d Mon Sep 17 00:00:00 2001 From: Javad Mokhtari Koushyar Date: Tue, 30 Jun 2026 17:31:38 -0500 Subject: [PATCH] fix: make set_key export output shell-safe --- src/dotenv/main.py | 34 ++++++++++++++++++++++++++++++---- tests/test_main.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 3c4608d5..e3a45afe 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -2,6 +2,7 @@ import logging import os import pathlib +import re import stat import sys import tempfile @@ -17,6 +18,8 @@ StrPath = Union[str, "os.PathLike[str]"] logger = logging.getLogger(__name__) +_posix_export_key = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_safe_unquoted_export_value = re.compile(r"^[A-Za-z0-9_@%+=:,./-]+$") def _load_dotenv_disabled() -> bool: @@ -39,6 +42,27 @@ def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding yield mapping +def _validate_export_key(key: str) -> None: + if _posix_export_key.fullmatch(key) is None: + raise ValueError(f"Invalid export key: {key}") + + +def _quote_export_value(value: str, quote: bool) -> str: + if not quote and _safe_unquoted_export_value.fullmatch(value) is not None: + return value + + if "'" not in value: + return f"'{value}'" + + escaped = ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("$", "\\$") + .replace("`", "\\`") + ) + return f'"{escaped}"' + + class DotEnv: def __init__( self, @@ -215,13 +239,15 @@ def set_key( quote_mode == "auto" and not value_to_set.isalnum() ) - if quote: - value_out = "'{}'".format(value_to_set.replace("'", "\\'")) - else: - value_out = value_to_set if export: + _validate_export_key(key_to_set) + value_out = _quote_export_value(value_to_set, quote) line_out = f"export {key_to_set}={value_out}\n" else: + if quote: + value_out = "'{}'".format(value_to_set.replace("'", "\\'")) + else: + value_out = value_to_set line_out = f"{key_to_set}={value_out}\n" with rewrite(dotenv_path, encoding=encoding, follow_symlinks=follow_symlinks) as ( diff --git a/tests/test_main.py b/tests/test_main.py index 1c33c808..57da52db 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -63,6 +63,38 @@ def test_set_key_encoding(dotenv_path): assert dotenv_path.read_text(encoding=encoding) == "a='é'\n" +@pytest.mark.skipif(sys.platform == "win32", reason="requires a POSIX shell") +def test_set_key_export_shell_escapes_single_quote_value(tmp_path): + dotenv_path = tmp_path / ".env" + marker_path = tmp_path / "pwned" + value = f"'; touch {marker_path}; #" + + dotenv.set_key(dotenv_path, "SAFE", value, export=True) + + env = os.environ.copy() + env["DOTENV_PATH"] = str(dotenv_path) + env["EXPECTED"] = value + result = subprocess.run( + ["bash", "-c", 'source "$DOTENV_PATH"; test "$SAFE" = "$EXPECTED"'], + env=env, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert not marker_path.exists() + + +def test_set_key_export_rejects_invalid_shell_key(tmp_path): + dotenv_path = tmp_path / ".env" + + with pytest.raises(ValueError, match="Invalid export key"): + dotenv.set_key(dotenv_path, "SAFE; touch pwned; #", "value", export=True) + + assert not dotenv_path.exists() + + @pytest.mark.skipif( sys.platform == "win32", reason="file mode bits behave differently on Windows" )