From 8777c27a5eaf38072a99be8a6125e97443fb472e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 17 Sep 2026 18:22:06 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(discovery):=20skip=20empty?= =?UTF-8?q?=20PATH=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_paths() turned every PATH entry into a Path via map(Path, ...) with no filtering. An empty entry - a leading, trailing, or doubled separator - means "current directory" per POSIX and Windows PATH semantics, so Path("") resolved to it and the caller's cwd got searched for interpreters alongside every real directory on PATH. Confirmed on a real machine: PATH=/usr/bin:/bin: with a python3.11-named file planted in an attacker-writable cwd got that file executed during discovery's own interrogation step, independent of whether it was ultimately selected as the result. filter(None, ...) drops the empty entries before they become Path objects. --- docs/changelog/129.bugfix.rst | 1 + src/python_discovery/_discovery.py | 7 ++++++- tests/test_discovery.py | 32 ++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 docs/changelog/129.bugfix.rst diff --git a/docs/changelog/129.bugfix.rst b/docs/changelog/129.bugfix.rst new file mode 100644 index 0000000..ad7d484 --- /dev/null +++ b/docs/changelog/129.bugfix.rst @@ -0,0 +1 @@ +Skip empty ``PATH`` entries during interpreter discovery - by :user:`gaborbernat`. diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index f6eda20..5bde25e 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -423,7 +423,12 @@ def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]: except (AttributeError, ValueError): # pragma: no cover # Windows only (no confstr) path = os.defpath if path: - for entry in map(Path, path.split(os.pathsep)): + # An empty component (a leading/trailing/doubled separator) means "the current directory", the + # same footgun a shell has when PATH is misconfigured - Path("") resolves to it. Since every + # yielded path here is later searched for interpreters to execute and interrogate, silently + # including the caller's CWD would let whatever directory virtualenv happens to run from smuggle + # in a candidate binary. + for entry in map(Path, filter(None, path.split(os.pathsep))): with suppress(OSError): if entry.is_dir() and next(entry.iterdir(), None): yield entry diff --git a/tests/test_discovery.py b/tests/test_discovery.py index c683099..ff278a8 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -161,6 +161,38 @@ def test_get_paths_no_path_env(monkeypatch: pytest.MonkeyPatch) -> None: assert paths +@pytest.mark.parametrize( + ("raw_path", "expected"), + [ + pytest.param("{first}{sep}", ["first"], id="trailing"), + pytest.param("{sep}{first}", ["first"], id="leading"), + pytest.param("{first}{sep}{sep}{second}", ["first", "second"], id="doubled"), + ], +) +def test_get_paths_skips_empty_entries(tmp_path: Path, raw_path: str, expected: list[str]) -> None: + """An empty PATH entry means "current directory" - it must never be searched for interpreters.""" + dirs = {} + for name in ("first", "second"): + dirs[name] = tmp_path / name + dirs[name].mkdir() + (dirs[name] / "dummy").touch() + + paths = list(get_paths({"PATH": raw_path.format(first=dirs["first"], second=dirs["second"], sep=os.pathsep)})) + + assert paths == [dirs[name] for name in expected] + + +def test_get_paths_empty_entry_does_not_yield_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Reproduces the exploit: an empty PATH entry used to resolve to the caller's CWD.""" + cwd_marker = tmp_path / "python3.11" + cwd_marker.touch(mode=0o755) + monkeypatch.chdir(tmp_path) + + paths = list(get_paths({"PATH": f"{os.pathsep}"})) + + assert not any(path.resolve() == tmp_path.resolve() for path in paths) + + def test_lazy_path_dump_debug(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("_VIRTUALENV_DEBUG", "1") a_dir = tmp_path