Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/changelog/205.security.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Validate pyenv, mise and asdf version names before using them as directory names. A version file can no longer direct
python-discovery's resolver to a binary outside the version manager's installation directory.
15 changes: 12 additions & 3 deletions docs/explanation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,20 @@ detects these shims and resolves them to the actual binary.

flowchart TD
Shim["Shim detected"] --> EnvVar{"PYENV_VERSION<br>set?"}
EnvVar -->|Yes| Use["Use that version"]
EnvVar -->|Yes| Valid{"Valid directory name?"}
EnvVar -->|No| File{".python-version<br>names a version?"}
File -->|Yes| Use
File -->|Yes| Valid
File -->|No| Global{"pyenv global<br>version exists?"}
Global -->|Yes| Use
Global -->|Yes| Valid
Global -->|No| Skip["Skip shim"]
Valid -->|Yes| Use["Use that version"]
Valid -->|No| More{"Another version?"}
More -->|Yes| Valid
More -->|No| Skip

style Shim fill:#4a90d9,stroke:#2a5f8f,color:#fff
style Valid fill:#4a90d9,stroke:#2a5f8f,color:#fff
style More fill:#4a90d9,stroke:#2a5f8f,color:#fff
style Use fill:#4a9f4a,stroke:#2a6f2a,color:#fff
style Skip fill:#d94a4a,stroke:#8f2a2a,color:#fff

Expand All @@ -69,6 +75,9 @@ detects these shims and resolves them to the actual binary.
python-discovery skips version files over 4096 characters. It checks parent directories and, for pyenv, the global
version when needed.

A version must name one directory below the version manager's installation root. Discovery skips ``.`` and ``..`` and
version names containing a path separator, then tries the next configured version.

How uv-managed Pythons are discovered
---------------------------------------

Expand Down
3 changes: 3 additions & 0 deletions src/python_discovery/_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,9 @@ def _resolve_shim(exe_path: str, env: Mapping[str, str]) -> str | None:

def _resolve_shim_to_binary(exe_name: str, versions_dir: str, env: Mapping[str, str]) -> str | None:
for version in _active_versions(env):
if version in {"", ".", ".."} or os.path.basename(version) != version:
_LOGGER.debug("ignoring invalid version name %r", version)
continue
resolved = os.path.join(versions_dir, version, "bin", exe_name)
if Path(resolved).is_file() and os.access(resolved, os.X_OK):
return resolved
Expand Down
55 changes: 55 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,61 @@ def test_shim_uses_python_version_file(tmp_path: Path, monkeypatch: pytest.Monke
assert mock_from_exe.call_args_list[0][0][0] == str(real_binary)


@pytest.mark.parametrize(
("version", "target"),
[
pytest.param("{outside}", "outside", id="absolute"),
pytest.param("../../outside", "outside", id="traversal"),
pytest.param("..", "root", id="parent"),
pytest.param(".", "versions", id="current"),
pytest.param("2.7.18/subdir", "nested", id="nested"),
],
)
def test_shim_skips_path_like_version(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, version: str, target: str
) -> None:
root = _create_version_manager(tmp_path, "PYENV_ROOT")
real_binary = _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7")
outside = tmp_path / "outside"
attack_root = {
"outside": outside,
"root": root,
"versions": root / "versions",
"nested": root / "versions" / "2.7.18" / "subdir",
}[target]
_create_versioned_binary(attack_root, (), "", "python2.7")
shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")
shim.touch(mode=0o755)
(tmp_path / ".python-version").write_text(f"{version.format(outside=outside)}\n2.7.18\n", encoding="utf-8")

monkeypatch.setenv("PATH", str(root / "shims"))
monkeypatch.setenv("PYENV_ROOT", str(root))
monkeypatch.delenv("PYENV_VERSION", raising=False)
monkeypatch.chdir(tmp_path)

with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe:
mock_from_exe.return_value = None
get_interpreter("python2.7", [])
assert mock_from_exe.call_args_list[0][0][0] == str(real_binary)


def test_shim_skips_empty_environment_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = _create_version_manager(tmp_path, "PYENV_ROOT")
real_binary = _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7")
_create_versioned_binary(root / "versions", (), "", "python2.7")
shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")
shim.touch(mode=0o755)

monkeypatch.setenv("PATH", str(root / "shims"))
monkeypatch.setenv("PYENV_ROOT", str(root))
monkeypatch.setenv("PYENV_VERSION", ":2.7.18")

with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe:
mock_from_exe.return_value = None
get_interpreter("python2.7", [])
assert mock_from_exe.call_args_list[0][0][0] == str(real_binary)


@pytest.mark.parametrize(
("length", "version"),
[pytest.param(4096, "2.7.18", id="at-limit"), pytest.param(4097, "2.7.15", id="over-limit")],
Expand Down