Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions src/dotenv/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import os
import pathlib
import re
import stat
import sys
import tempfile
Expand All @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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 (
Expand Down
32 changes: 32 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down