diff --git a/pip-CVE-2026-8643.patch b/pip-CVE-2026-8643.patch new file mode 100644 index 0000000..361938a --- /dev/null +++ b/pip-CVE-2026-8643.patch @@ -0,0 +1,140 @@ +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 diff --git a/python3.14-pip.spec b/python3.14-pip.spec index 03ff2b0..afe0934 100644 --- a/python3.14-pip.spec +++ b/python3.14-pip.spec @@ -109,6 +109,10 @@ Patch: downstream-remove-pytest-subket.patch # Upstream fix: https://github.com/urllib3/urllib3/commit/f05b1329126d5be6de501f9d1e3e36738bc08857 Patch: urllib3-CVE-2025-50181.patch +# Reject entry point names that escape scripts dir (CVE-2026-8643) +# Upstream fix: https://github.com/pypa/pip/commit/8eb178480bd1a2b223f509fc430796b265158dfb +Patch: pip-CVE-2026-8643.patch + # Remove -s from Python shebang - ensure that packages installed with pip # to user locations are seen by pip itself %undefine _py3_shebang_s