diff --git a/docs/changelog/205.security.rst b/docs/changelog/205.security.rst new file mode 100644 index 0000000..4591d0c --- /dev/null +++ b/docs/changelog/205.security.rst @@ -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. diff --git a/docs/explanation.rst b/docs/explanation.rst index 2b1ca5e..a03d7d9 100644 --- a/docs/explanation.rst +++ b/docs/explanation.rst @@ -53,14 +53,20 @@ detects these shims and resolves them to the actual binary. flowchart TD Shim["Shim detected"] --> EnvVar{"PYENV_VERSION
set?"} - EnvVar -->|Yes| Use["Use that version"] + EnvVar -->|Yes| Valid{"Valid directory name?"} EnvVar -->|No| File{".python-version
names a version?"} - File -->|Yes| Use + File -->|Yes| Valid File -->|No| Global{"pyenv global
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 @@ -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 --------------------------------------- diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index 9109f60..23338cc 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -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 diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 2440a03..786fb26 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -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")],