141 lines
5.5 KiB
Diff
141 lines
5.5 KiB
Diff
From dba24a5eeb065d5ac552b3049d2cc99c0bda13fb Mon Sep 17 00:00:00 2001
|
|
From: Damian Shaw <damian.peter.shaw@gmail.com>
|
|
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
|