From dba24a5eeb065d5ac552b3049d2cc99c0bda13fb Mon Sep 17 00:00:00 2001 From: Damian Shaw Date: Wed, 20 May 2026 15:20:25 -0400 Subject: [PATCH] Reject entry point names that escape scripts dir (#14000) * Reject entry point names that escape scripts dir * NEWS ENTRY --- news/14000.bugfix.rst | 2 + src/pip/_internal/operations/install/wheel.py | 26 +++++++- tests/unit/test_wheel.py | 64 +++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/pip/_internal/operations/install/wheel.py b/src/pip/_internal/operations/install/wheel.py index 2724f150f..ec2faaa5d 100644 --- a/src/pip/_internal/operations/install/wheel.py +++ b/src/pip/_internal/operations/install/wheel.py @@ -397,11 +397,31 @@ class MissingCallableSuffix(InstallationError): ) -def _raise_for_invalid_entrypoint(specification: str) -> None: +def _script_within_dir(name: str, scripts_dir: str) -> bool: + """Return whether script ``name`` resolves to a path inside the ``scripts_dir``. + + distlib joins the entry point name onto the scripts directory, so a name + with path separators or ``..`` components can resolve elsewhere. + """ + root = os.path.normpath(scripts_dir) + dest = os.path.normpath(os.path.join(scripts_dir, name)) + return dest.startswith(root + os.sep) + + +def _raise_for_invalid_entrypoint(specification: str, scripts_dir: str) -> None: entry = get_export_entry(specification) - if entry is not None and entry.suffix is None: + if entry is None: + return + + if entry.suffix is None: raise MissingCallableSuffix(str(entry)) + if not _script_within_dir(entry.name, scripts_dir): + raise InstallationError( + f"Invalid script entry point name {entry.name!r}: the script " + f"would be installed outside the scripts directory ({scripts_dir})." + ) + class PipScriptMaker(ScriptMaker): # Override distlib's default script template with one that @@ -420,7 +440,7 @@ class PipScriptMaker(ScriptMaker): def make( self, specification: str, options: dict[str, Any] | None = None ) -> list[str]: - _raise_for_invalid_entrypoint(specification) + _raise_for_invalid_entrypoint(specification, self.target_dir) return super().make(specification, options) diff --git a/tests/unit/test_wheel.py b/tests/unit/test_wheel.py index 028ad3a46..bce0607ec 100644 --- a/tests/unit/test_wheel.py +++ b/tests/unit/test_wheel.py @@ -519,6 +519,32 @@ class TestInstallUnpackedWheel: assert os.path.basename(wheel_path) in exc_text assert entrypoint in exc_text + @pytest.mark.parametrize("bad_name", ["../../outside", "..", "."]) + @pytest.mark.parametrize("entry_point_type", ["console_scripts", "gui_scripts"]) + def test_wheel_install_rejects_entry_point_path_traversal( + self, data: TestData, tmpdir: Path, bad_name: str, entry_point_type: str + ) -> None: + """An entry point name with separators or ``..`` must not install a + script outside the scripts directory. + """ + self.prep(data, tmpdir) + wheel_path = make_wheel( + "simple", + "0.1.0", + entry_points={entry_point_type: [f"{bad_name} = simple:main"]}, + ).save_to_dir(tmpdir) + with pytest.raises(InstallationError) as e: + wheel.install_wheel( + "simple", + str(wheel_path), + scheme=self.scheme, + req_description="simple", + ) + + assert "outside the scripts directory" in str(e.value) + # Nothing was written outside the install destination. + assert not os.path.exists(os.path.join(str(tmpdir), "outside")) + class TestMessageAboutScriptsNotOnPATH: tilde_warning_msg = ( @@ -722,3 +748,41 @@ def test_get_console_script_specs_replaces_python_version( "not_pip_or_easy_install-99 = whatever", "not_pip_or_easy_install-99.88 = whatever", ] + + +@pytest.mark.parametrize( + "name, within", + [ + ("pip", True), + ("pip3.13", True), + ("foo-bar.baz", True), + ("...", True), # a literal filename, not a path component + ("sub/script", True), # in-tree subdirectory + ("a/../b", True), + ("sub\\script", True), # backslash stays in-tree on POSIX and Windows + (" ../../inside", True), # distlib keeps a leading space; resolves in-tree + ("../outside", False), + ("../../outside", False), + ("a/../../outside", False), + ("/etc/cron.d/outside", False), # absolute path; os.path.join drops the root + # "." and ".." pass PyPI's [\w.-]+ name check but must be rejected here. + (".", False), + ("..", False), + ("", False), + ], +) +def test_script_within_dir(name: str, within: bool) -> None: + assert wheel._script_within_dir(name, "/srv/env/bin") is within + + +def test_script_within_dir_allows_doubled_slash_root() -> None: + # A scripts directory can have a doubled leading slash + assert wheel._script_within_dir("pip", "//srv/env/bin") is True + assert wheel._script_within_dir("../outside", "//srv/env/bin") is False + + +@pytest.mark.skipif(not WINDOWS, reason="drive letters only matter on Windows") +def test_script_within_dir_rejects_other_drive() -> None: + # Validate that a script on a different drive is rejected, + # and doesn't throw an error + assert wheel._script_within_dir("D:\\outside", "C:\\env\\bin") is False