python3.14-pip/pip-CVE-2026-8643.patch
2026-07-07 12:59:24 -04:00

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