From 3f3ab3fbada5af3b6c8d14c9e8daa873104f4cbb Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Tue, 28 Apr 2026 13:20:07 +0200 Subject: [PATCH 01/14] Update to 26.1.1 (rhbz#2466579) --- python-pip.spec | 19 +++---- ...existing-dist-only-if-path-conflicts.patch | 18 +++---- sources | 2 +- urllib3-CVE-2025-50181.patch | 51 ------------------- 4 files changed, 16 insertions(+), 74 deletions(-) delete mode 100644 urllib3-CVE-2025-50181.patch diff --git a/python-pip.spec b/python-pip.spec index 69a90aa..7861c2e 100644 --- a/python-pip.spec +++ b/python-pip.spec @@ -6,7 +6,7 @@ %bcond man 1 %global srcname pip -%global base_version 26.0.1 +%global base_version 26.1.1 %global upstream_version %{base_version}%{?prerel} %global python_wheel_name %{srcname}-%{upstream_version}-py3-none-any.whl @@ -20,7 +20,6 @@ Summary: A tool for installing and managing Python packages # certifi: MPL-2.0 # CacheControl: Apache-2.0 -# dependency-groups: MIT # distlib: Python-2.0.1 # distro: Apache-2.0 # idna: BSD-3-Clause @@ -96,11 +95,6 @@ Patch: dummy-certifi.patch # We don't need a layer to check that, as we're by default in an offline environment Patch: downstream-remove-pytest-subket.patch -# Patch for the bundled urllib3 for CVE-2025-50181 -# Redirects are not disabled when retries are disabled on PoolManager instantiation -# Upstream fix: https://github.com/urllib3/urllib3/commit/f05b1329126d5be6de501f9d1e3e36738bc08857 -Patch: urllib3-CVE-2025-50181.patch - # Remove -s from Python shebang - ensure that packages installed with pip # to user locations are seen by pip itself %undefine _py3_shebang_s @@ -118,24 +112,23 @@ Packages" or "Pip Installs Python". # %%{_rpmconfigdir}/pythonbundles.py --namespace 'python%%{1}dist' src/pip/_vendor/vendor.txt %global bundled() %{expand: Provides: bundled(python%{1}dist(cachecontrol)) = 0.14.4 -Provides: bundled(python%{1}dist(certifi)) = 2026.1.4 -Provides: bundled(python%{1}dist(dependency-groups)) = 1.3.1 +Provides: bundled(python%{1}dist(certifi)) = 2026.2.25 Provides: bundled(python%{1}dist(distlib)) = 0.4 Provides: bundled(python%{1}dist(distro)) = 1.9 Provides: bundled(python%{1}dist(idna)) = 3.11 Provides: bundled(python%{1}dist(msgpack)) = 1.1.2 -Provides: bundled(python%{1}dist(packaging)) = 26 +Provides: bundled(python%{1}dist(packaging)) = 26.2 Provides: bundled(python%{1}dist(platformdirs)) = 4.5.1 Provides: bundled(python%{1}dist(pygments)) = 2.19.2 Provides: bundled(python%{1}dist(pyproject-hooks)) = 1.2 -Provides: bundled(python%{1}dist(requests)) = 2.32.5 +Provides: bundled(python%{1}dist(requests)) = 2.33.1 Provides: bundled(python%{1}dist(resolvelib)) = 1.2.1 Provides: bundled(python%{1}dist(rich)) = 14.2 Provides: bundled(python%{1}dist(setuptools)) = 70.3 -Provides: bundled(python%{1}dist(tomli)) = 2.3 +Provides: bundled(python%{1}dist(tomli)) = 2.3.1 Provides: bundled(python%{1}dist(tomli-w)) = 1.2 Provides: bundled(python%{1}dist(truststore)) = 0.10.4 -Provides: bundled(python%{1}dist(urllib3)) = 1.26.20 +Provides: bundled(python%{1}dist(urllib3)) = 2.6.3 } # Some manylinux1 wheels need libcrypt.so.1. diff --git a/remove-existing-dist-only-if-path-conflicts.patch b/remove-existing-dist-only-if-path-conflicts.patch index d280f34..02d0c69 100644 --- a/remove-existing-dist-only-if-path-conflicts.patch +++ b/remove-existing-dist-only-if-path-conflicts.patch @@ -1,4 +1,4 @@ -From d5cb806a14bb50a96484bca6536e81f2ac316b9d Mon Sep 17 00:00:00 2001 +From a12217cf8f9bf3ab8e39d9bcc6b42e7043e79b08 Mon Sep 17 00:00:00 2001 From: Karolina Surma Date: Wed, 16 Feb 2022 08:36:21 +0100 Subject: [PATCH] Prevent removing of the system packages installed under @@ -53,10 +53,10 @@ index 230e114..8bd5d31 100644 class BaseEnvironment: """An environment containing distributions to introspect.""" diff --git a/src/pip/_internal/req/req_install.py b/src/pip/_internal/req/req_install.py -index c9f6bff..c101826 100644 +index aad2f0e..7b385b1 100644 --- a/src/pip/_internal/req/req_install.py +++ b/src/pip/_internal/req/req_install.py -@@ -453,7 +453,7 @@ class InstallRequirement: +@@ -439,7 +439,7 @@ class InstallRequirement: f"lack sys.path precedence to {existing_dist.raw_name} " f"in {existing_dist.location}" ) @@ -66,7 +66,7 @@ index c9f6bff..c101826 100644 else: if self.editable: diff --git a/src/pip/_internal/resolution/legacy/resolver.py b/src/pip/_internal/resolution/legacy/resolver.py -index 33a4fdc..1fe886e 100644 +index 6cc6311..4188ef1 100644 --- a/src/pip/_internal/resolution/legacy/resolver.py +++ b/src/pip/_internal/resolution/legacy/resolver.py @@ -322,7 +322,9 @@ class Resolver(BaseResolver): @@ -81,11 +81,11 @@ index 33a4fdc..1fe886e 100644 req.satisfied_by = None diff --git a/src/pip/_internal/resolution/resolvelib/factory.py b/src/pip/_internal/resolution/resolvelib/factory.py -index f23e4cd..1bada79 100644 +index a74200a..99738cc 100644 --- a/src/pip/_internal/resolution/resolvelib/factory.py +++ b/src/pip/_internal/resolution/resolvelib/factory.py -@@ -3,6 +3,8 @@ from __future__ import annotations - import contextlib +@@ -4,6 +4,8 @@ import contextlib + import copy import functools import logging +import sys @@ -93,7 +93,7 @@ index f23e4cd..1bada79 100644 from collections.abc import Iterable, Iterator, Mapping, Sequence from typing import ( TYPE_CHECKING, -@@ -615,6 +617,16 @@ class Factory: +@@ -674,6 +676,16 @@ class Factory: if dist is None: # Not installed, no uninstallation required. return None @@ -111,5 +111,5 @@ index f23e4cd..1bada79 100644 # be uninstalled, no matter it's in global or user site, because the # user site installation has precedence over global. -- -2.50.1 +2.54.0 diff --git a/sources b/sources index 5cd1652..a50ad4c 100644 --- a/sources +++ b/sources @@ -1,4 +1,4 @@ -SHA512 (pip-26.0.1.tar.gz) = 3786df7522ea65bc20fb9885ce5c2ddc60200a536a1f754a8d7dc278115c73258863e4c51ac7e9a60dda0b70263730dc194f70e1e8f8d00178a8b3c724333bf0 SHA512 (setuptools-79.0.1-py3-none-any.whl) = fef6cfc6f95a5bb7320f1680e1c665cb8d9a4e4227cde4d8aab8a50bed4bcf04320085b9d7d5343359f887008db5c5a861e57f3d08b7b0b2311a28adaeee6b4a SHA512 (flit_core-3.12.0-py3-none-any.whl) = 790c12b1f43201e365fb3f8f2f0a54e1a578876799dfdf8bfeea679a25ea096bf62946d006618c1458ae6e37ce6d00998f37e9aba426d5ab80d32ef2d75da4e0 +SHA512 (pip-26.1.1.tar.gz) = 777fab14b5e7e7edeffbc7ae86f4e6061e9f7cf43adcaebcb4d099c184c4f2ad6a63019a70d95b98b631821c3472713c9545fb8d65b684da67cfc31ee9293054 SHA512 (coverage-0-py3-none-any.whl) = e734192565347010efe68f8ba600254259c9b647f3c553fd4e5d87b1d7f955cb15d6f7d807716f4a6415d239beed945fbec7210feaf502e9cc849c332845926e diff --git a/urllib3-CVE-2025-50181.patch b/urllib3-CVE-2025-50181.patch deleted file mode 100644 index 05e6353..0000000 --- a/urllib3-CVE-2025-50181.patch +++ /dev/null @@ -1,51 +0,0 @@ -From b3d543d7e16af844394316360ef1bf0b9d10f1b1 Mon Sep 17 00:00:00 2001 -From: Illia Volochii -Date: Wed, 18 Jun 2025 16:25:01 +0300 -Subject: [PATCH] Security fix for CVE-2025-50181 - -Co-authored-by: Seth Michael Larson -Co-authored-by: Quentin Pradet -Co-authored-by: Seth Michael Larson ---- - src/pip/_vendor/urllib3/poolmanager.py | 18 +++++++++++++++++- - 1 file changed, 17 insertions(+), 1 deletion(-) - -diff --git a/src/pip/_vendor/urllib3/poolmanager.py b/src/pip/_vendor/urllib3/poolmanager.py -index fb51bf7..a8de7c6 100644 ---- a/src/pip/_vendor/urllib3/poolmanager.py -+++ b/src/pip/_vendor/urllib3/poolmanager.py -@@ -170,6 +170,22 @@ class PoolManager(RequestMethods): - - def __init__(self, num_pools=10, headers=None, **connection_pool_kw): - RequestMethods.__init__(self, headers) -+ if "retries" in connection_pool_kw: -+ retries = connection_pool_kw["retries"] -+ if not isinstance(retries, Retry): -+ # When Retry is initialized, raise_on_redirect is based -+ # on a redirect boolean value. -+ # But requests made via a pool manager always set -+ # redirect to False, and raise_on_redirect always ends -+ # up being False consequently. -+ # Here we fix the issue by setting raise_on_redirect to -+ # a value needed by the pool manager without considering -+ # the redirect boolean. -+ raise_on_redirect = retries is not False -+ retries = Retry.from_int(retries, redirect=False) -+ retries.raise_on_redirect = raise_on_redirect -+ connection_pool_kw = connection_pool_kw.copy() -+ connection_pool_kw["retries"] = retries - self.connection_pool_kw = connection_pool_kw - self.pools = RecentlyUsedContainer(num_pools) - -@@ -389,7 +405,7 @@ class PoolManager(RequestMethods): - kw["body"] = None - kw["headers"] = HTTPHeaderDict(kw["headers"])._prepare_for_method_change() - -- retries = kw.get("retries") -+ retries = kw.get("retries", response.retries) - if not isinstance(retries, Retry): - retries = Retry.from_int(retries, redirect=redirect) - --- -2.51.0 - From a5bfb663f271879e08d03932e19832db210a083f Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Wed, 29 Apr 2026 20:36:19 +0200 Subject: [PATCH 02/14] Remove Python 3.9 from tests; pip no longer supports it since 26.1. --- plan.fmf | 7 ------- 1 file changed, 7 deletions(-) diff --git a/plan.fmf b/plan.fmf index 72ad07b..01c13a2 100644 --- a/plan.fmf +++ b/plan.fmf @@ -9,9 +9,6 @@ discover: - name: smoke36 path: /smoke test: VERSION=3.6 TOX=false ./venv.sh - - name: smoke39 - path: /smoke - test: VERSION=3.9 ./venv.sh - name: smoke310 path: /smoke test: VERSION=3.10 ./venv.sh @@ -30,9 +27,6 @@ discover: - name: smoke315 path: /smoke test: VERSION=3.15 ./venv.sh - - name: smoke39_virtualenv - path: /smoke - test: VERSION=3.9 METHOD=virtualenv ./venv.sh - name: smoke310_virtualenv path: /smoke test: VERSION=3.10 METHOD=virtualenv ./venv.sh @@ -89,7 +83,6 @@ prepare: - gcc - virtualenv - python3.6-devel - - python3.9-devel - python3.10-devel - python3.11-devel - python3.12-devel From 5fd8a26f31437569c50e05afc5a4e668bab9ffac Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Thu, 30 Apr 2026 20:05:08 +0200 Subject: [PATCH 03/14] Remove Python 3.6 from CI configuration --- plan.fmf | 4 ---- 1 file changed, 4 deletions(-) diff --git a/plan.fmf b/plan.fmf index 01c13a2..dca520b 100644 --- a/plan.fmf +++ b/plan.fmf @@ -6,9 +6,6 @@ discover: how: shell url: https://src.fedoraproject.org/tests/python.git tests: - - name: smoke36 - path: /smoke - test: VERSION=3.6 TOX=false ./venv.sh - name: smoke310 path: /smoke test: VERSION=3.10 ./venv.sh @@ -82,7 +79,6 @@ prepare: package: - gcc - virtualenv - - python3.6-devel - python3.10-devel - python3.11-devel - python3.12-devel From c8ed4920c4bda815e915ccb094bc89d578e98094 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miro=20Hron=C4=8Dok?= Date: Tue, 19 May 2026 18:46:29 +0200 Subject: [PATCH 04/14] Never provide pip if this is not the build for the main Python [skip changelog] --- python-pip.spec | 2 ++ 1 file changed, 2 insertions(+) diff --git a/python-pip.spec b/python-pip.spec index 7861c2e..c2b620d 100644 --- a/python-pip.spec +++ b/python-pip.spec @@ -170,7 +170,9 @@ Requires: ca-certificates # Virtual provides for the packages bundled by pip: %{bundled %{python3_pkgversion}} +%if "%{python3_pkgversion}" == "3" Provides: pip = %{version}-%{release} +%endif %description -n python%{python3_pkgversion}-%{srcname} pip is a package management system used to install and manage software packages From 9583373c974b07f0023a35298439905082421906 Mon Sep 17 00:00:00 2001 From: Python Maint Date: Wed, 3 Jun 2026 13:47:16 +0200 Subject: [PATCH 05/14] Bootstrap for Python 3.15 --- python-pip.spec | 2 ++ 1 file changed, 2 insertions(+) diff --git a/python-pip.spec b/python-pip.spec index c2b620d..d0ccf85 100644 --- a/python-pip.spec +++ b/python-pip.spec @@ -1,3 +1,5 @@ +%global _without_man 1 +%global _without_tests 1 # The original RHEL N+1 content set is defined by (build)dependencies # of the packages in Fedora ELN. Hence we disable tests here # to prevent pulling many unwanted packages in. From 3f4a4319aee477f9427739b1baca45d460fafc39 Mon Sep 17 00:00:00 2001 From: Python Maint Date: Wed, 3 Jun 2026 13:59:55 +0200 Subject: [PATCH 06/14] Bump release From 415ed181e5e4d4dc392469cb5808086a9f18728a Mon Sep 17 00:00:00 2001 From: Python Maint Date: Thu, 4 Jun 2026 21:52:04 +0200 Subject: [PATCH 07/14] Rebuilt for Python 3.15 --- python-pip.spec | 2 -- 1 file changed, 2 deletions(-) diff --git a/python-pip.spec b/python-pip.spec index d0ccf85..c2b620d 100644 --- a/python-pip.spec +++ b/python-pip.spec @@ -1,5 +1,3 @@ -%global _without_man 1 -%global _without_tests 1 # The original RHEL N+1 content set is defined by (build)dependencies # of the packages in Fedora ELN. Hence we disable tests here # to prevent pulling many unwanted packages in. From 9d73fb50fcc06da506ba3a91960ee2c4a9351471 Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Wed, 1 Jul 2026 14:54:22 +0200 Subject: [PATCH 08/14] Update to 26.1.2 (rhbz#2483638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Miro Hrončok --- 4c6d7471de.patch | 29 ++++++++++++++++++++++ 6099a54ddd.patch | 62 ++++++++++++++++++++++++++++++++++++++++++++++++ python-pip.spec | 19 +++++++++++---- sources | 2 +- 4 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 4c6d7471de.patch create mode 100644 6099a54ddd.patch diff --git a/4c6d7471de.patch b/4c6d7471de.patch new file mode 100644 index 0000000..d107561 --- /dev/null +++ b/4c6d7471de.patch @@ -0,0 +1,29 @@ +From 4c6d7471dec62fb004a47a7c2164b6b5b089ac06 Mon Sep 17 00:00:00 2001 +From: Richard Si +Date: Fri, 5 Jun 2026 15:44:14 -0400 +Subject: [PATCH] Also fix user site patching in test suite + +--- + tests/lib/venv.py | 6 ++++-- + 1 file changed, 4 insertions(+), 2 deletions(-) + +diff --git a/tests/lib/venv.py b/tests/lib/venv.py +index 67b01d9f31..4e86b92b3b 100644 +--- a/tests/lib/venv.py ++++ b/tests/lib/venv.py +@@ -174,11 +174,13 @@ def _customize_site(self) -> None: + site.ENABLE_USER_SITE = {self._user_site_packages} + # First, drop system-sites related paths. + original_sys_path = sys.path[:] ++ # To discover system-sites related paths, clear sys.path ++ # and build a new one with only system paths. ++ sys.path = [] + known_paths = set() + for path in site.getsitepackages(): + site.addsitedir(path, known_paths=known_paths) +- system_paths = sys.path[len(original_sys_path):] +- for path in system_paths: ++ for path in sys.path: + if path in original_sys_path: + original_sys_path.remove(path) + sys.path = original_sys_path diff --git a/6099a54ddd.patch b/6099a54ddd.patch new file mode 100644 index 0000000..0bc8ea7 --- /dev/null +++ b/6099a54ddd.patch @@ -0,0 +1,62 @@ +From 6099a54dddbfbc7fb912d53b6adad5ff6b8d1745 Mon Sep 17 00:00:00 2001 +From: Richard Si +Date: Fri, 5 Jun 2026 15:03:38 -0400 +Subject: [PATCH] Fix sitecustomize.py used for build isolation on Python 3.15+ + +The sitecustomize.py file pip uses to isolate build subprocesses from +the parent environment discovers system related paths by calling +site.addsitedir() for every system site-packages path and observing +what new entries are appended to sys.path. + +This breaks since Python 3.15b2 due to two changes: + +- site.addsitedir() won't add a path if it already exists in sys.path + +- site.addsitedir() won't re-execute .pth files if called for a known + directory (which includes the system sites because known_path is + mutated by addsitedir before it checks for .pth files) + +To cope with this, temporarily clear sys.path before using +site.addsitedir() to discover all system paths for exclusion. +--- + news/14033.bugfix.rst | 1 + + src/pip/_internal/build_env.py | 14 +++++++++----- + 2 files changed, 10 insertions(+), 5 deletions(-) + create mode 100644 news/14033.bugfix.rst + +diff --git a/news/14033.bugfix.rst b/news/14033.bugfix.rst +new file mode 100644 +index 0000000000..404196a2f0 +--- /dev/null ++++ b/news/14033.bugfix.rst +@@ -0,0 +1 @@ ++Prevent system packages from leaking into isolated build environments on Python 3.15 +diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py +index 1a42a9d411..7639dabcad 100644 +--- a/src/pip/_internal/build_env.py ++++ b/src/pip/_internal/build_env.py +@@ -468,15 +468,19 @@ def __init__(self, installer: BuildEnvironmentInstaller) -> None: + """ + import os, site, sys + +- # First, drop system-sites related paths. ++ # First, discover all system-sites related paths. + original_sys_path = sys.path[:] ++ # Clear sys.path so addsitedir() will add system site paths and paths ++ # added by contained .pth files to sys.path reliably. This is necessary ++ # since Python 3.15, which notably no longer re-executes .pth files for ++ # known paths. ++ sys.path = [] + known_paths = set() + for path in {system_sites!r}: + site.addsitedir(path, known_paths=known_paths) +- system_paths = set( +- os.path.normcase(path) +- for path in sys.path[len(original_sys_path):] +- ) ++ system_paths = set(os.path.normcase(path) for path in sys.path) ++ ++ # Drop discovered system-sites related paths. + original_sys_path = [ + path for path in original_sys_path + if os.path.normcase(path) not in system_paths diff --git a/python-pip.spec b/python-pip.spec index c2b620d..cf3940e 100644 --- a/python-pip.spec +++ b/python-pip.spec @@ -6,7 +6,7 @@ %bcond man 1 %global srcname pip -%global base_version 26.1.1 +%global base_version 26.1.2 %global upstream_version %{base_version}%{?prerel} %global python_wheel_name %{srcname}-%{upstream_version}-py3-none-any.whl @@ -95,6 +95,18 @@ Patch: dummy-certifi.patch # We don't need a layer to check that, as we're by default in an offline environment Patch: downstream-remove-pytest-subket.patch +# Fix sitecustomize.py used for build isolation on Python 3.15+ +Patch: https://github.com/pypa/pip/commit/6099a54ddd.patch + +# Fix user-site path ordering in the test suite on Python 3.15+ +# The same CPython gh-149819 change that broke build env isolation also broke +# _customize_site() in tests/lib/venv.py: site.addsitedir() no longer +# re-appends paths already in sys.path, so the detection of system-site paths +# produces an empty list and user site ends up after venv site-packages instead +# of before it, causing user-site install/uninstall tests to operate on the +# wrong installation. +Patch: https://github.com/pypa/pip/commit/4c6d7471de.patch + # Remove -s from Python shebang - ensure that packages installed with pip # to user locations are seen by pip itself %undefine _py3_shebang_s @@ -309,9 +321,8 @@ grep "pem$" %{pyproject_files} && exit 1 || true pytest_k='not completion' # this clashes with our PYTHONPATH pytest_k="$pytest_k and not environments_with_no_pip" -# this seems to require internet (despite no network marker) -# added in https://github.com/pypa/pip/pull/13378 TODO drop this in the next release -pytest_k="$pytest_k and not test_prompt_for_keyring_if_needed and not test_double_install_fail and not test_install_sdist_links and not test_lock_vcs and not test_lock_archive and not test_backend_sees_config_via_sdist" +# this requires internet without the keyring local wheel +pytest_k="$pytest_k and not test_prompt_for_keyring_if_needed" # this cannot import breezy, TODO investigate pytest_k="$pytest_k and not (functional and bazaar)" # failures to investigate diff --git a/sources b/sources index a50ad4c..786a390 100644 --- a/sources +++ b/sources @@ -1,4 +1,4 @@ SHA512 (setuptools-79.0.1-py3-none-any.whl) = fef6cfc6f95a5bb7320f1680e1c665cb8d9a4e4227cde4d8aab8a50bed4bcf04320085b9d7d5343359f887008db5c5a861e57f3d08b7b0b2311a28adaeee6b4a SHA512 (flit_core-3.12.0-py3-none-any.whl) = 790c12b1f43201e365fb3f8f2f0a54e1a578876799dfdf8bfeea679a25ea096bf62946d006618c1458ae6e37ce6d00998f37e9aba426d5ab80d32ef2d75da4e0 -SHA512 (pip-26.1.1.tar.gz) = 777fab14b5e7e7edeffbc7ae86f4e6061e9f7cf43adcaebcb4d099c184c4f2ad6a63019a70d95b98b631821c3472713c9545fb8d65b684da67cfc31ee9293054 +SHA512 (pip-26.1.2.tar.gz) = e29c98a7da5e329183b7eef86a66f9d6c3473051f64aa6e762714306148547eb0de4220824484071822a9a62bd01a62a09ab16bba4c26e4b847bfc2609728608 SHA512 (coverage-0-py3-none-any.whl) = e734192565347010efe68f8ba600254259c9b647f3c553fd4e5d87b1d7f955cb15d6f7d807716f4a6415d239beed945fbec7210feaf502e9cc849c332845926e From 47955c1bf675123e49961ada78c5bbea96c03b98 Mon Sep 17 00:00:00 2001 From: Fedora Release Engineering Date: Thu, 16 Jul 2026 20:49:58 +0000 Subject: [PATCH 09/14] Rebuilt for https://fedoraproject.org/wiki/Fedora_45_Mass_Rebuild From c05c4a66bbcec89de43f9022868cc899f44900a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miro=20Hron=C4=8Dok?= Date: Thu, 20 Aug 2026 20:13:16 +0200 Subject: [PATCH 10/14] Allow flit-core 4 for building this package --- 09a03f6cfa.patch | 38 ++++++++++++++++++++++++++++++++++++++ python-pip.spec | 4 ++++ 2 files changed, 42 insertions(+) create mode 100644 09a03f6cfa.patch diff --git a/09a03f6cfa.patch b/09a03f6cfa.patch new file mode 100644 index 0000000..b6369a6 --- /dev/null +++ b/09a03f6cfa.patch @@ -0,0 +1,38 @@ +From 09a03f6cfaeecae8bf5774ae371d37c4369e2da4 Mon Sep 17 00:00:00 2001 +From: Damian Shaw +Date: Sat, 15 Aug 2026 13:36:05 -0400 +Subject: [PATCH] Allow flit-core 4 to build pip + +--- + pyproject.toml | 6 +++--- + 1 file changed, 3 insertions(+), 3 deletions(-) + +diff --git a/pyproject.toml b/pyproject.toml +index 48f64c8018..fe6b39913d 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -45,13 +45,13 @@ Source = "https://github.com/pypa/pip" + Changelog = "https://pip.pypa.io/en/stable/news/" + + [build-system] +-requires = ["flit-core >=3.11,<4"] ++requires = ["flit-core >=3.11,<5"] + build-backend = "flit_core.buildapi" + + [dependency-groups] + test = [ + "cryptography", +- "flit-core >= 3.11, < 4", ++ "flit-core >= 3.11, < 5", + "freezegun", + "installer", + # pytest-subket requires 7.0+ +@@ -69,7 +69,7 @@ test = [ + ] + + test-common-wheels = [ +- "flit-core >= 3.11, < 4", ++ "flit-core >= 3.11, < 5", + # We pin setuptools<80 because our test suite currently + # depends on setup.py develop to generate egg-link files. + "setuptools >= 70.1.0, <80", diff --git a/python-pip.spec b/python-pip.spec index cf3940e..61fd6d1 100644 --- a/python-pip.spec +++ b/python-pip.spec @@ -107,6 +107,10 @@ Patch: https://github.com/pypa/pip/commit/6099a54ddd.patch # wrong installation. Patch: https://github.com/pypa/pip/commit/4c6d7471de.patch +# Allow flit-core 4 to build pip +# https://github.com/pypa/pip/commit/09a03f6cfa (non-existing files removed) +Patch: 09a03f6cfa.patch + # Remove -s from Python shebang - ensure that packages installed with pip # to user locations are seen by pip itself %undefine _py3_shebang_s From 08dc5bf450f6c0cd1e3e87e270d0a5d96064ff4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Zachar?= Date: Wed, 18 Mar 2026 12:16:01 +0100 Subject: [PATCH 11/14] CI: Make some test not running for ELN (and RHEL) See also https://forge.fedoraproject.org/ci/tickets/issues/550 [skip changelog] --- plan.fmf | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/plan.fmf b/plan.fmf index dca520b..c3765d1 100644 --- a/plan.fmf +++ b/plan.fmf @@ -6,6 +6,17 @@ discover: how: shell url: https://src.fedoraproject.org/tests/python.git tests: + - name: smoke + path: /smoke + test: ./venv.sh + - name: smoke_virtualenv + path: /smoke + test: METHOD=virtualenv ./venv.sh + - name: tests_python_fedora_only + how: shell + url: https://src.fedoraproject.org/tests/python.git + when: distro != fedora-eln and distro == fedora + tests: - name: smoke310 path: /smoke test: VERSION=3.10 ./venv.sh @@ -79,12 +90,6 @@ prepare: package: - gcc - virtualenv - - python3.10-devel - - python3.11-devel - - python3.12-devel - - python3.13-devel - - python3.14-devel - - python3.15-devel - python3-devel - python3-tox - mock @@ -95,6 +100,16 @@ prepare: - shadow-utils - expect - dnf + - name: Install dependencies (Fedora not ELN) + how: install + when: distro != fedora-eln and distro == fedora + package: + - python3.10-devel + - python3.11-devel + - python3.12-devel + - python3.13-devel + - python3.14-devel + - python3.15-devel - name: Update packages how: shell script: dnf upgrade -y From 40c9b4419c45204aa711a33330c3b00688c56729 Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Fri, 14 Aug 2026 15:05:08 +0200 Subject: [PATCH 12/14] Security fixes for CVE-2026-13346 and CVE-2026-8643 --- CVE-2026-13346.patch | 341 +++++++++++++++++++++++++++++++++++++++++++ CVE-2026-8643.patch | 139 ++++++++++++++++++ python-pip.spec | 8 + 3 files changed, 488 insertions(+) create mode 100644 CVE-2026-13346.patch create mode 100644 CVE-2026-8643.patch diff --git a/CVE-2026-13346.patch b/CVE-2026-13346.patch new file mode 100644 index 0000000..b38fd25 --- /dev/null +++ b/CVE-2026-13346.patch @@ -0,0 +1,341 @@ +From 6686b964762255a933e68939bb49710216ae7bb9 Mon Sep 17 00:00:00 2001 +From: Damian Shaw +Date: Fri, 14 Aug 2026 09:49:19 +0000 +Subject: [PATCH 2/2] Fix Link.filename double URL decode - path traversal + (CVE-2026-13346) + +Upstream PRs: https://github.com/pypa/pip/pull/14110 + +- Add PathComponent newtype to enforce single-component filenames +- Remove double urllib.parse.unquote() call in Link.filename +- Add join_within_directory() preventing path escape at download sites +- Update download.py and prepare.py call sites +- Backport test coverage from upstream commits 1, 3, 4 of PR #14110 + +Co-Authored-By: Lumir Balhar +--- + src/pip/_internal/models/link.py | 61 ++++++++++--- + src/pip/_internal/network/download.py | 20 +++-- + src/pip/_internal/operations/prepare.py | 6 +- + tests/unit/test_link.py | 113 +++++++++++++++++++++++- + 4 files changed, 179 insertions(+), 21 deletions(-) + +diff --git a/src/pip/_internal/models/link.py b/src/pip/_internal/models/link.py +index 200ec34..e6a0c87 100644 +--- a/src/pip/_internal/models/link.py ++++ b/src/pip/_internal/models/link.py +@@ -14,6 +14,7 @@ from dataclasses import dataclass + from typing import ( + Any, + NamedTuple, ++ NewType, + ) + + from pip._internal.exceptions import InvalidEggFragment +@@ -31,6 +32,47 @@ from pip._internal.utils.urls import path_to_url, url_to_path + logger = logging.getLogger(__name__) + + ++# A single path component: percent-decoded once and reduced to a basename, so it ++# contains no path separator and is not a ``.`` or ``..`` reference. The empty ++# string means "no component". ++PathComponent = NewType("PathComponent", str) ++ ++ ++def _to_path_component(name: str) -> PathComponent: ++ """Reduce ``name`` to a single path component, or ``""`` if it has none. ++ ++ ``os.path.basename`` drops any directory part, drive letter, or separator; ++ a ``.``, ``..``, or empty result is not a component and becomes ``""``. ++ """ ++ name = os.path.basename(name) ++ if name in ("", os.curdir, os.pardir): ++ return PathComponent("") ++ ++ return PathComponent(name) ++ ++ ++def as_path_component(name: str) -> PathComponent: ++ """Like ``_to_path_component`` but reject the empty result. ++ ++ Use where a file is about to be written, so a missing name is an error ++ rather than a silent fallback to the directory itself. ++ """ ++ component = _to_path_component(name) ++ if not component: ++ raise ValueError(f"Unexpected file name derived from URL: {name!r}") ++ ++ return component ++ ++ ++def join_within_directory(directory: str, component: PathComponent) -> str: ++ """Join a single path ``component`` onto ``directory``. ++ ++ ``component`` is a :data:`PathComponent`, so by type it has no separator and ++ is not a ``.`` or ``..`` reference; the result can never escape ``directory``. ++ """ ++ return os.path.join(directory, component) ++ ++ + # Order matters, earlier hashes have a precedence over later hashes for what + # we will pick to use. + _SUPPORTED_HASHES = ("sha512", "sha384", "sha256", "sha224", "sha1", "md5") +@@ -423,18 +465,13 @@ class Link: + return redact_auth_from_url(self.url) + + @property +- def filename(self) -> str: +- path = self.path.rstrip("/") +- name = posixpath.basename(path) +- if not name: +- # Make sure we don't leak auth information if the netloc +- # includes a username and password. +- netloc, user_pass = split_auth_from_netloc(self.netloc) +- return netloc +- +- name = urllib.parse.unquote(name) +- assert name, f"URL {self._url!r} produced no filename" +- return name ++ def filename(self) -> PathComponent: ++ name = _to_path_component(posixpath.basename(self.path.rstrip("/"))) ++ if name: ++ return name ++ ++ # No component in the path; fall back to the netloc, dropping any auth. ++ return _to_path_component(split_auth_from_netloc(self.netloc)[0]) + + @property + def file_path(self) -> str: +diff --git a/src/pip/_internal/network/download.py b/src/pip/_internal/network/download.py +index 2696642..fa71c75 100644 +--- a/src/pip/_internal/network/download.py ++++ b/src/pip/_internal/network/download.py +@@ -20,7 +20,12 @@ from pip._vendor.urllib3.exceptions import ReadTimeoutError + from pip._internal.cli.progress_bars import BarType, get_download_progress_renderer + from pip._internal.exceptions import IncompleteDownloadError, NetworkConnectionError + from pip._internal.models.index import PyPI +-from pip._internal.models.link import Link ++from pip._internal.models.link import ( ++ Link, ++ PathComponent, ++ as_path_component, ++ join_within_directory, ++) + from pip._internal.network.cache import SafeFileCache, is_from_cache + from pip._internal.network.session import CacheControlAdapter, PipSession + from pip._internal.network.utils import HEADERS, raise_for_status, response_chunks +@@ -117,11 +122,14 @@ def parse_content_disposition(content_disposition: str, default_filename: str) - + return filename or default_filename + + +-def _get_http_response_filename(resp: Response, link: Link) -> str: ++def _get_http_response_filename(resp: Response, link: Link) -> PathComponent: + """Get an ideal filename from the given HTTP response, falling back to + the link filename if not provided. ++ ++ The result is validated as a single path component, so it can be joined onto ++ a download directory without escaping it. + """ +- filename = link.filename # fallback ++ filename: str = link.filename # fallback + # Have a look at the Content-Disposition header for a better guess + content_disposition = resp.headers.get("content-disposition") + if content_disposition: +@@ -135,7 +143,7 @@ def _get_http_response_filename(resp: Response, link: Link) -> str: + ext = os.path.splitext(resp.url)[1] + if ext: + filename += ext +- return filename ++ return as_path_component(filename) + + + @dataclass +@@ -188,7 +196,9 @@ class Downloader: + resp = self._http_get(link) + download_size = _get_http_response_size(resp) + +- filepath = os.path.join(location, _get_http_response_filename(resp, link)) ++ filepath = join_within_directory( ++ location, _get_http_response_filename(resp, link) ++ ) + with open(filepath, "wb") as content_file: + download = _FileDownload(link, content_file, download_size) + self._process_response(download, resp) +diff --git a/src/pip/_internal/operations/prepare.py b/src/pip/_internal/operations/prepare.py +index 67f9ee9..d260d15 100644 +--- a/src/pip/_internal/operations/prepare.py ++++ b/src/pip/_internal/operations/prepare.py +@@ -29,7 +29,7 @@ from pip._internal.exceptions import ( + from pip._internal.index.package_finder import PackageFinder + from pip._internal.metadata import BaseDistribution, get_metadata_distribution + from pip._internal.models.direct_url import ArchiveInfo +-from pip._internal.models.link import Link ++from pip._internal.models.link import Link, join_within_directory + from pip._internal.models.wheel import Wheel + from pip._internal.network.download import Downloader + from pip._internal.network.lazy_wheel import ( +@@ -201,7 +201,7 @@ def _check_download_dir( + """Check download_dir for previously downloaded file with correct hash + If a correct file is found return its path else None + """ +- download_path = os.path.join(download_dir, link.filename) ++ download_path = join_within_directory(download_dir, link.filename) + + if not os.path.exists(download_path): + return None +@@ -683,7 +683,7 @@ class RequirementPreparer: + # No distribution was downloaded for this requirement. + return + +- download_location = os.path.join(self.download_dir, link.filename) ++ download_location = join_within_directory(self.download_dir, link.filename) + if not os.path.exists(download_location): + shutil.copy(req.local_file_path, download_location) + download_path = display_path(download_location) +diff --git a/tests/unit/test_link.py b/tests/unit/test_link.py +index c49f854..bc8cb8a 100644 +--- a/tests/unit/test_link.py ++++ b/tests/unit/test_link.py +@@ -1,9 +1,17 @@ + from __future__ import annotations + ++import os ++import posixpath ++ + import pytest + + from pip._internal.exceptions import InvalidEggFragment, PipError +-from pip._internal.models.link import Link, links_equivalent ++from pip._internal.models.link import ( ++ Link, ++ as_path_component, ++ join_within_directory, ++ links_equivalent, ++) + from pip._internal.utils.hashes import Hashes + + +@@ -29,6 +37,13 @@ class TestLink: + ("https://example.com/path/page.html", "page.html"), + # Test a quoted character. + ("https://example.com/path/page%231.html", "page#1.html"), ++ # A doubly-encoded separator must stay encoded: the path is decoded ++ # exactly once, so the file name keeps its literal "%2F" instead of ++ # collapsing into a "/". ++ ( ++ "https://example.com/a%252Fb.whl", ++ "a%2Fb.whl", ++ ), + ( + "http://yo/myproject-1.0%2Bfoobar.0-py2.py3-none-any.whl", + "myproject-1.0+foobar.0-py2.py3-none-any.whl", +@@ -49,6 +64,52 @@ class TestLink: + link = Link(url) + assert link.filename == expected + ++ @pytest.mark.parametrize( ++ "url", ++ [ ++ "https://example.com/a%252Fb.whl", ++ "https://example.com/%252e%252e%252fb.whl", ++ ], ++ ) ++ def test_filename_decoded_once_stays_single_component(self, url: str) -> None: ++ # The path is decoded exactly once, so an encoded separator stays ++ # encoded and the file name remains a single path component rather ++ # than collapsing into a "/"-separated path. ++ filename = Link(url).filename ++ assert not posixpath.isabs(filename) ++ assert posixpath.basename(filename) == filename ++ ++ @pytest.mark.parametrize( ++ "url", ++ [ ++ "https://example.com/..", ++ "https://example.com/.", ++ "https://example.com/foo/%2e%2e", ++ ], ++ ) ++ def test_filename_parent_reference_falls_back_to_netloc(self, url: str) -> None: ++ # A path that is only a "." or ".." reference has no usable file name, ++ # so filename falls back to the netloc rather than handing back a ++ # traversal component that could escape a download directory. ++ assert Link(url).filename == "example.com" ++ ++ @pytest.mark.parametrize( ++ "url", ++ [ ++ # A path-less URL whose authority looks like a traversal: the netloc ++ # fallback must still reduce to a single path component. ++ "http://..\\..\\..\\evil.whl", ++ "http://../", ++ "http://..", ++ ], ++ ) ++ def test_filename_is_always_a_path_component(self, url: str) -> None: ++ # filename must never carry a separator or parent reference, so joining ++ # it onto a directory can never escape that directory. ++ name = Link(url).filename ++ assert os.path.basename(name) == name ++ assert name not in (os.curdir, os.pardir) ++ + def test_splitext(self) -> None: + assert ("wheel", ".whl") == Link("http://yo/wheel.whl").splitext() + +@@ -244,3 +305,53 @@ def test_links_equivalent(url1: str, url2: str) -> None: + ) + def test_links_equivalent_false(url1: str, url2: str) -> None: + assert not links_equivalent(Link(url1), Link(url2)) ++ ++ ++@pytest.mark.parametrize( ++ "name", ++ [ ++ "wheel.whl", ++ "myproject-1.0+foobar.0-py2.py3-none-any.whl", ++ # A literal "%2F" is a normal file name, not a separator. ++ "a%2Fb.whl", ++ ], ++) ++def test_as_path_component_keeps_plain_name(name: str) -> None: ++ assert as_path_component(name) == name ++ ++ ++@pytest.mark.parametrize( ++ "name", ++ [ ++ os.path.join(os.sep, "abs", "pkg.whl"), ++ os.path.join("..", "pkg.whl"), ++ os.path.join("nested", "pkg.whl"), ++ ], ++) ++def test_as_path_component_reduces_to_basename(name: str) -> None: ++ # A name carrying directory components is reduced to its basename, so the ++ # result always stays inside the directory it is later joined onto. ++ assert as_path_component(name) == os.path.basename(name) ++ ++ ++@pytest.mark.parametrize("name", ["", ".", "..", "/", os.path.join("sub", "..")]) ++def test_as_path_component_rejects_empty_or_parent_reference(name: str) -> None: ++ with pytest.raises(ValueError): ++ as_path_component(name) ++ ++ ++@pytest.mark.parametrize( ++ "name", ++ [ ++ "pkg.whl", ++ # A literal "%2F" is a normal file name, not a separator. ++ "a%2Fb.whl", ++ ], ++) ++def test_join_within_directory_stays_inside(name: str) -> None: ++ # The component is joined onto the directory as its final element, so the ++ # result stays inside the directory. ++ directory = os.path.join("base", "downloads") ++ joined = join_within_directory(directory, as_path_component(name)) ++ assert joined == os.path.join(directory, name) ++ assert os.path.basename(joined) == name +-- +2.55.0 + diff --git a/CVE-2026-8643.patch b/CVE-2026-8643.patch new file mode 100644 index 0000000..ebc91ab --- /dev/null +++ b/CVE-2026-8643.patch @@ -0,0 +1,139 @@ +From c840c212830bb8e9dab56de6c57916b111858aec Mon Sep 17 00:00:00 2001 +From: Damian Shaw +Date: Mon, 18 May 2026 23:04:43 -0400 +Subject: [PATCH 1/2] Reject entry point names that escape scripts dir + +--- + 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 40097d6..231e400 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 +@@ -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 e0ac649..c1aafe5 100644 +--- a/tests/unit/test_wheel.py ++++ b/tests/unit/test_wheel.py +@@ -462,6 +462,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 = ( +@@ -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 +-- +2.55.0 + diff --git a/python-pip.spec b/python-pip.spec index 69a90aa..01c3de6 100644 --- a/python-pip.spec +++ b/python-pip.spec @@ -101,6 +101,14 @@ Patch: downstream-remove-pytest-subket.patch # Upstream fix: https://github.com/urllib3/urllib3/commit/f05b1329126d5be6de501f9d1e3e36738bc08857 Patch: urllib3-CVE-2025-50181.patch +# CVE-2026-8643: entry point path traversal in console_scripts/gui_scripts +# Upstream fix: https://github.com/pypa/pip/pull/14000 +Patch: CVE-2026-8643.patch + +# CVE-2026-13346: Link.filename double URL decode allows path traversal +# Upstream fix: https://github.com/pypa/pip/pull/14110 +Patch: CVE-2026-13346.patch + # Remove -s from Python shebang - ensure that packages installed with pip # to user locations are seen by pip itself %undefine _py3_shebang_s From ac655bc5de633400527d3c4165d9166d41619e3a Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Fri, 14 Aug 2026 15:05:08 +0200 Subject: [PATCH 13/14] Security fix for CVE-2026-13346 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-by: Miro Hrončok --- 10dfb6b900.patch | 347 +++++++++++++++++++++++++++++++++++++++++++++++ python-pip.spec | 3 + 2 files changed, 350 insertions(+) create mode 100644 10dfb6b900.patch diff --git a/10dfb6b900.patch b/10dfb6b900.patch new file mode 100644 index 0000000..5b56c7e --- /dev/null +++ b/10dfb6b900.patch @@ -0,0 +1,347 @@ +From 10dfb6b9005484578b386f64b9f36982e3dc6679 Mon Sep 17 00:00:00 2001 +From: Damian Shaw +Date: Tue, 30 Jun 2026 21:52:39 -0400 +Subject: [PATCH] Fix Link.filename decoding URL path twice (#14110) + +Link already percent-decodes the URL path into `self._path`, but +`Link.filename` decoded the basename again, so a doubly-encoded +separator was decoded twice: `%252F` became `%2F` in `__init__`, then +`/` in `filename`, turning the single component `a%2Fb.whl` into +`a/b.whl`. + +Drop the second decode, and add a `join_within_directory` helper so the +download-path joins treat the name as a single path component. +--- + news/14110.bugfix.rst | 1 + + src/pip/_internal/models/link.py | 63 ++++++++++--- + src/pip/_internal/network/download.py | 20 +++-- + src/pip/_internal/operations/prepare.py | 6 +- + tests/unit/test_link.py | 113 +++++++++++++++++++++++- + 5 files changed, 182 insertions(+), 21 deletions(-) + create mode 100644 news/14110.bugfix.rst + +diff --git a/news/14110.bugfix.rst b/news/14110.bugfix.rst +new file mode 100644 +index 0000000000..f7d4f78882 +--- /dev/null ++++ b/news/14110.bugfix.rst +@@ -0,0 +1 @@ ++Fix ``Link.filename`` decoding the URL path twice. +diff --git a/src/pip/_internal/models/link.py b/src/pip/_internal/models/link.py +index 0a09c66222..cbbe945c17 100644 +--- a/src/pip/_internal/models/link.py ++++ b/src/pip/_internal/models/link.py +@@ -13,6 +13,7 @@ + from typing import ( + Any, + NamedTuple, ++ NewType, + ) + + from pip._internal.exceptions import InvalidEggFragment +@@ -30,6 +31,49 @@ + logger = logging.getLogger(__name__) + + ++# A single path component: percent-decoded once and reduced to a basename, so it ++# contains no path separator and is not a ``.`` or ``..`` reference. The empty ++# string means "no component". ++PathComponent = NewType("PathComponent", str) ++ ++ ++def _to_path_component(name: str) -> PathComponent: ++ """Reduce ``name`` to a single path component, or ``""`` if it has none. ++ ++ ``os.path.basename`` drops any directory part, drive letter, or separator; ++ a ``.``, ``..``, or empty result is not a component and becomes ``""``. ++ """ ++ name = os.path.basename(name) ++ if name in ("", os.curdir, os.pardir): ++ return PathComponent("") ++ ++ return PathComponent(name) ++ ++ ++def as_path_component(name: str) -> PathComponent: ++ """Like ``_to_path_component`` but reject the empty result. ++ ++ Use where a file is about to be written, so a missing name is an error ++ rather than a silent fallback to the directory itself. ++ """ ++ component = _to_path_component(name) ++ if not component: ++ raise ValueError(f"Unexpected file name derived from URL: {name!r}") ++ ++ return component ++ ++ ++def join_within_directory(directory: str, component: PathComponent) -> str: ++ """Join a single path ``component`` onto ``directory``. ++ ++ ``component`` is a :data:`PathComponent`, so by type it has no separator and ++ is not a ``.`` or ``..`` reference; the result can never escape ``directory``. ++ Requiring ``PathComponent`` rather than ``str`` lets the type checker enforce ++ at the call site that the name was reduced to a safe component beforehand. ++ """ ++ return os.path.join(directory, component) ++ ++ + # Order matters, earlier hashes have a precedence over later hashes for what + # we will pick to use. + _SUPPORTED_HASHES = ("sha512", "sha384", "sha256", "sha224", "sha1", "md5") +@@ -424,18 +468,13 @@ def redacted_url(self) -> str: + return redact_auth_from_url(self.url) + + @property +- def filename(self) -> str: +- path = self.path.rstrip("/") +- name = posixpath.basename(path) +- if not name: +- # Make sure we don't leak auth information if the netloc +- # includes a username and password. +- netloc, user_pass = split_auth_from_netloc(self.netloc) +- return netloc +- +- name = urllib.parse.unquote(name) +- assert name, f"URL {self._url!r} produced no filename" +- return name ++ def filename(self) -> PathComponent: ++ name = _to_path_component(posixpath.basename(self.path.rstrip("/"))) ++ if name: ++ return name ++ ++ # No component in the path; fall back to the netloc, dropping any auth. ++ return _to_path_component(split_auth_from_netloc(self.netloc)[0]) + + @property + def file_path(self) -> str: +diff --git a/src/pip/_internal/network/download.py b/src/pip/_internal/network/download.py +index 039b268878..6faafb5cb0 100644 +--- a/src/pip/_internal/network/download.py ++++ b/src/pip/_internal/network/download.py +@@ -19,7 +19,12 @@ + + from pip._internal.cli.progress_bars import BarType, get_download_progress_renderer + from pip._internal.exceptions import IncompleteDownloadError, NetworkConnectionError +-from pip._internal.models.link import Link ++from pip._internal.models.link import ( ++ Link, ++ PathComponent, ++ as_path_component, ++ join_within_directory, ++) + from pip._internal.network.cache import SafeFileCache, is_from_cache + from pip._internal.network.session import CacheControlAdapter, PipSession + from pip._internal.network.utils import HEADERS, raise_for_status, response_chunks +@@ -121,11 +126,14 @@ def parse_content_disposition(content_disposition: str, default_filename: str) - + return filename or default_filename + + +-def _get_http_response_filename(resp: Response, link: Link) -> str: ++def _get_http_response_filename(resp: Response, link: Link) -> PathComponent: + """Get an ideal filename from the given HTTP response, falling back to + the link filename if not provided. ++ ++ The result is validated as a single path component, so it can be joined onto ++ a download directory without escaping it. + """ +- filename = link.filename # fallback ++ filename: str = link.filename # fallback + # Have a look at the Content-Disposition header for a better guess + content_disposition = resp.headers.get("content-disposition") + if content_disposition: +@@ -139,7 +147,7 @@ def _get_http_response_filename(resp: Response, link: Link) -> str: + ext = os.path.splitext(resp.url)[1] + if ext: + filename += ext +- return filename ++ return as_path_component(filename) + + + @dataclass +@@ -192,7 +200,9 @@ def __call__(self, link: Link, location: str) -> tuple[str, str]: + resp = self._http_get(link) + download_size = _get_http_response_size(resp) + +- filepath = os.path.join(location, _get_http_response_filename(resp, link)) ++ filepath = join_within_directory( ++ location, _get_http_response_filename(resp, link) ++ ) + with open(filepath, "wb") as content_file: + download = _FileDownload(link, content_file, download_size) + self._process_response(download, resp) +diff --git a/src/pip/_internal/operations/prepare.py b/src/pip/_internal/operations/prepare.py +index afcc0376da..3b44403e0d 100644 +--- a/src/pip/_internal/operations/prepare.py ++++ b/src/pip/_internal/operations/prepare.py +@@ -29,7 +29,7 @@ + from pip._internal.index.package_finder import PackageFinder + from pip._internal.metadata import BaseDistribution, get_metadata_distribution + from pip._internal.models.direct_url import ArchiveInfo, DirectUrl +-from pip._internal.models.link import Link ++from pip._internal.models.link import Link, join_within_directory + from pip._internal.models.wheel import Wheel + from pip._internal.network.download import Downloader + from pip._internal.network.lazy_wheel import ( +@@ -201,7 +201,7 @@ def _check_download_dir( + """Check download_dir for previously downloaded file with correct hash + If a correct file is found return its path else None + """ +- download_path = os.path.join(download_dir, link.filename) ++ download_path = join_within_directory(download_dir, link.filename) + + if not os.path.exists(download_path): + return None +@@ -687,7 +687,7 @@ def save_linked_requirement(self, req: InstallRequirement) -> None: + # No distribution was downloaded for this requirement. + return + +- download_location = os.path.join(self.download_dir, link.filename) ++ download_location = join_within_directory(self.download_dir, link.filename) + if not os.path.exists(download_location): + shutil.copy(req.local_file_path, download_location) + download_path = display_path(download_location) +diff --git a/tests/unit/test_link.py b/tests/unit/test_link.py +index c49f8547ac..bc8cb8ab9b 100644 +--- a/tests/unit/test_link.py ++++ b/tests/unit/test_link.py +@@ -1,9 +1,17 @@ + from __future__ import annotations + ++import os ++import posixpath ++ + import pytest + + from pip._internal.exceptions import InvalidEggFragment, PipError +-from pip._internal.models.link import Link, links_equivalent ++from pip._internal.models.link import ( ++ Link, ++ as_path_component, ++ join_within_directory, ++ links_equivalent, ++) + from pip._internal.utils.hashes import Hashes + + +@@ -29,6 +37,13 @@ def test_repr(self, url: str, expected: str) -> None: + ("https://example.com/path/page.html", "page.html"), + # Test a quoted character. + ("https://example.com/path/page%231.html", "page#1.html"), ++ # A doubly-encoded separator must stay encoded: the path is decoded ++ # exactly once, so the file name keeps its literal "%2F" instead of ++ # collapsing into a "/". ++ ( ++ "https://example.com/a%252Fb.whl", ++ "a%2Fb.whl", ++ ), + ( + "http://yo/myproject-1.0%2Bfoobar.0-py2.py3-none-any.whl", + "myproject-1.0+foobar.0-py2.py3-none-any.whl", +@@ -49,6 +64,52 @@ def test_filename(self, url: str, expected: str) -> None: + link = Link(url) + assert link.filename == expected + ++ @pytest.mark.parametrize( ++ "url", ++ [ ++ "https://example.com/a%252Fb.whl", ++ "https://example.com/%252e%252e%252fb.whl", ++ ], ++ ) ++ def test_filename_decoded_once_stays_single_component(self, url: str) -> None: ++ # The path is decoded exactly once, so an encoded separator stays ++ # encoded and the file name remains a single path component rather ++ # than collapsing into a "/"-separated path. ++ filename = Link(url).filename ++ assert not posixpath.isabs(filename) ++ assert posixpath.basename(filename) == filename ++ ++ @pytest.mark.parametrize( ++ "url", ++ [ ++ "https://example.com/..", ++ "https://example.com/.", ++ "https://example.com/foo/%2e%2e", ++ ], ++ ) ++ def test_filename_parent_reference_falls_back_to_netloc(self, url: str) -> None: ++ # A path that is only a "." or ".." reference has no usable file name, ++ # so filename falls back to the netloc rather than handing back a ++ # traversal component that could escape a download directory. ++ assert Link(url).filename == "example.com" ++ ++ @pytest.mark.parametrize( ++ "url", ++ [ ++ # A path-less URL whose authority looks like a traversal: the netloc ++ # fallback must still reduce to a single path component. ++ "http://..\\..\\..\\evil.whl", ++ "http://../", ++ "http://..", ++ ], ++ ) ++ def test_filename_is_always_a_path_component(self, url: str) -> None: ++ # filename must never carry a separator or parent reference, so joining ++ # it onto a directory can never escape that directory. ++ name = Link(url).filename ++ assert os.path.basename(name) == name ++ assert name not in (os.curdir, os.pardir) ++ + def test_splitext(self) -> None: + assert ("wheel", ".whl") == Link("http://yo/wheel.whl").splitext() + +@@ -244,3 +305,53 @@ def test_links_equivalent(url1: str, url2: str) -> None: + ) + def test_links_equivalent_false(url1: str, url2: str) -> None: + assert not links_equivalent(Link(url1), Link(url2)) ++ ++ ++@pytest.mark.parametrize( ++ "name", ++ [ ++ "wheel.whl", ++ "myproject-1.0+foobar.0-py2.py3-none-any.whl", ++ # A literal "%2F" is a normal file name, not a separator. ++ "a%2Fb.whl", ++ ], ++) ++def test_as_path_component_keeps_plain_name(name: str) -> None: ++ assert as_path_component(name) == name ++ ++ ++@pytest.mark.parametrize( ++ "name", ++ [ ++ os.path.join(os.sep, "abs", "pkg.whl"), ++ os.path.join("..", "pkg.whl"), ++ os.path.join("nested", "pkg.whl"), ++ ], ++) ++def test_as_path_component_reduces_to_basename(name: str) -> None: ++ # A name carrying directory components is reduced to its basename, so the ++ # result always stays inside the directory it is later joined onto. ++ assert as_path_component(name) == os.path.basename(name) ++ ++ ++@pytest.mark.parametrize("name", ["", ".", "..", "/", os.path.join("sub", "..")]) ++def test_as_path_component_rejects_empty_or_parent_reference(name: str) -> None: ++ with pytest.raises(ValueError): ++ as_path_component(name) ++ ++ ++@pytest.mark.parametrize( ++ "name", ++ [ ++ "pkg.whl", ++ # A literal "%2F" is a normal file name, not a separator. ++ "a%2Fb.whl", ++ ], ++) ++def test_join_within_directory_stays_inside(name: str) -> None: ++ # The component is joined onto the directory as its final element, so the ++ # result stays inside the directory. ++ directory = os.path.join("base", "downloads") ++ joined = join_within_directory(directory, as_path_component(name)) ++ assert joined == os.path.join(directory, name) ++ assert os.path.basename(joined) == name diff --git a/python-pip.spec b/python-pip.spec index 61fd6d1..81a54a7 100644 --- a/python-pip.spec +++ b/python-pip.spec @@ -111,6 +111,9 @@ Patch: https://github.com/pypa/pip/commit/4c6d7471de.patch # https://github.com/pypa/pip/commit/09a03f6cfa (non-existing files removed) Patch: 09a03f6cfa.patch +# CVE-2026-13346: Link.filename double URL decode allows path traversal +Patch: https://github.com/pypa/pip/commit/10dfb6b900.patch + # Remove -s from Python shebang - ensure that packages installed with pip # to user locations are seen by pip itself %undefine _py3_shebang_s From e0d87e4172eb7699ad78d48503ac278bf6b71f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miro=20Hron=C4=8Dok?= Date: Wed, 26 Aug 2026 09:28:13 +0200 Subject: [PATCH 14/14] Do not install the pip.whl with executable permissions The wheel file is never executed, it has no shebang (yes, zips can have shebangs). bash: /usr/share/python-wheels/pip-....whl: cannot execute binary file: Exec format error This executable permission was never intended, it only happens to be the default for the install command. --- python-pip.spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python-pip.spec b/python-pip.spec index 81a54a7..732bf9c 100644 --- a/python-pip.spec +++ b/python-pip.spec @@ -305,7 +305,7 @@ sed -i -e "s/^\\(complete.*\\) pip%{python3_version}\$/\\1 pip%{python3_version} # Install the built wheel and inject SBOM into it (if the macro is available) mkdir -p %{buildroot}%{python_wheel_dir} -install -p %{_pyproject_wheeldir}/%{python_wheel_name} -t %{buildroot}%{python_wheel_dir} +install -pm0644 %{_pyproject_wheeldir}/%{python_wheel_name} -t %{buildroot}%{python_wheel_dir} %{?python_wheel_inject_sbom:%python_wheel_inject_sbom %{buildroot}%{python_wheel_dir}/%{python_wheel_name}}