From 6be8d69432b113dcbb3776f6771c18829c221433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 15:05:15 -0800 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20fix(shims):=20confine=20vers?= =?UTF-8?q?ion=20names=20to=20installs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A version from a shared parent directory could escape the version manager root and cause discovery to probe another user's binary. Reject path-like version names before building the candidate path. --- docs/explanation.rst | 3 ++ src/python_discovery/_discovery.py | 3 ++ tests/test_discovery.py | 55 ++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/docs/explanation.rst b/docs/explanation.rst index 14ab839..7e9743d 100644 --- a/docs/explanation.rst +++ b/docs/explanation.rst @@ -65,6 +65,9 @@ detects these shims and resolves them to the actual binary. `mise `_ and `asdf `_ work similarly, using the ``MISE_DATA_DIR`` and ``ASDF_DATA_DIR`` environment variables to locate their installations. +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 99fbb59..5c1b0ef 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -509,6 +509,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 73d6f84..3cc9d89 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -401,6 +401,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) + + def test_shim_python_version_file_not_utf8( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, pyenv_probed: Callable[[], tuple[str, ...]] ) -> None: From f44dc9e2836c2d5611d7aa593945bbc61899e6e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 15:06:19 -0800 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=93=9D=20docs(news):=20record=20shim?= =?UTF-8?q?=20version=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/205.security.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 docs/changelog/205.security.rst 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. From 54a2623a1bc8d7c8530e45ad5854c074bd983f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 15:08:43 -0800 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=93=9D=20docs(shims):=20show=20invali?= =?UTF-8?q?d=20version=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/explanation.rst | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/explanation.rst b/docs/explanation.rst index 7e9743d..85d70db 100644 --- a/docs/explanation.rst +++ b/docs/explanation.rst @@ -51,14 +51,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