diff --git a/news/14000.bugfix.rst b/news/14000.bugfix.rst new file mode 100644 index 0000000000..3b86f1b3b0 --- /dev/null +++ b/news/14000.bugfix.rst @@ -0,0 +1,2 @@ +Reject ``console_scripts`` and ``gui_scripts`` entry points whose name would +install a script outside the scripts directory. diff --git a/src/pip/_internal/operations/install/wheel.py b/src/pip/_internal/operations/install/wheel.py index 40097d6a79..231e400658 100644 --- a/src/pip/_internal/operations/install/wheel.py +++ b/src/pip/_internal/operations/install/wheel.py @@ -397,11 +397,31 @@ def __init__(self, entry_point: str) -> None: ) -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 @@ -419,7 +439,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 3d78a97b98..4259f2b1de 100644 --- a/tests/unit/test_wheel.py +++ b/tests/unit/test_wheel.py @@ -462,6 +462,32 @@ def test_invalid_entrypoints_fail( 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 = ( @@ -665,3 +691,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