python3.14-pip/pip-CVE-2026-8643.patch
RHEL Packaging Agent b1803d0f02 Security fix for CVE-2026-8643: path traversal in wheel entry points
Backport upstream commit 8eb178480bd1a2b223f509fc430796b265158dfb
to fix CVE-2026-8643. The patch adds validation to reject wheel
entry point names containing path traversal components (e.g. ".."
or absolute paths) that could cause scripts to be installed
outside the intended scripts directory. A new _script_within_dir
helper function and updated _raise_for_invalid_entrypoint logic
in pip's wheel installer enforce this check. Comprehensive tests
are included.

CVE: CVE-2026-8643
Upstream patches:
 - 8eb178480b.patch
Resolves: RHEL-191157

This commit was backported by Ymir, a Red Hat Enterprise Linux software maintenance AI agent.

Assisted-by: Ymir
2026-07-08 09:32:27 +02: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