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")],