From a1b6765c8d7240fbf8ee09e539d6b32d623273b9 Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Fri, 14 Aug 2026 15:05:08 +0200 Subject: [PATCH] Security fixes for CVE-2026-13346 and CVE-2026-8643 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 40c9b4419c45204aa711a33330c3b00688c56729) Co-Authored-By: Miro HronĨok --- CVE-2026-13346.patch | 336 +++++++++++++++++++++++++++++++++++++++++++ CVE-2026-8643.patch | 137 ++++++++++++++++++ python-pip.spec | 8 ++ 3 files changed, 481 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..509e9f4 --- /dev/null +++ b/CVE-2026-13346.patch @@ -0,0 +1,336 @@ +From 2411dd7e5f20c605322f3cb64f10d2f181e4e796 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 | 59 ++++++++++--- + src/pip/_internal/network/download.py | 18 ++-- + src/pip/_internal/operations/prepare.py | 6 +- + tests/unit/test_link.py | 112 +++++++++++++++++++++++- + 4 files changed, 175 insertions(+), 20 deletions(-) + +diff --git a/src/pip/_internal/models/link.py b/src/pip/_internal/models/link.py +index 38423d1..6a855e2 100644 +--- a/src/pip/_internal/models/link.py ++++ b/src/pip/_internal/models/link.py +@@ -14,6 +14,7 @@ from typing import ( + List, + Mapping, + NamedTuple, ++ NewType, + Optional, + Tuple, + Union, +@@ -36,6 +37,47 @@ if TYPE_CHECKING: + 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") +@@ -413,18 +455,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 ++ def filename(self) -> PathComponent: ++ name = _to_path_component(posixpath.basename(self.path.rstrip("/"))) ++ if name: ++ return name + +- name = urllib.parse.unquote(name) +- assert name, f"URL {self._url!r} produced no filename" +- 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 15ef58b..f1c1592 100644 +--- a/src/pip/_internal/network/download.py ++++ b/src/pip/_internal/network/download.py +@@ -13,7 +13,12 @@ from pip._vendor.urllib3.exceptions import ReadTimeoutError + from pip._internal.cli.progress_bars import 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 is_from_cache + from pip._internal.network.session import PipSession + from pip._internal.network.utils import HEADERS, raise_for_status, response_chunks +@@ -110,11 +115,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: +@@ -128,7 +136,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) + + + def _http_get_download( +@@ -179,7 +187,7 @@ class Downloader: + content_type = resp.headers.get("Content-Type", "") + + filename = _get_http_response_filename(resp, link) +- filepath = os.path.join(location, filename) ++ filepath = join_within_directory(location, filename) + + with open(filepath, "wb") as content_file: + bytes_received = self._process_response( +diff --git a/src/pip/_internal/operations/prepare.py b/src/pip/_internal/operations/prepare.py +index 531070a..c8fef4d 100644 +--- a/src/pip/_internal/operations/prepare.py ++++ b/src/pip/_internal/operations/prepare.py +@@ -26,7 +26,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 BatchDownloader, Downloader + from pip._internal.network.lazy_wheel import ( +@@ -195,7 +195,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 +@@ -673,7 +673,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 a379d87..dc9cdbf 100644 +--- a/tests/unit/test_link.py ++++ b/tests/unit/test_link.py +@@ -1,8 +1,15 @@ ++import os ++import posixpath + from typing import Optional + + import pytest + +-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 + + +@@ -28,6 +35,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", +@@ -48,6 +62,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() + +@@ -240,3 +300,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..17976d3 --- /dev/null +++ b/CVE-2026-8643.patch @@ -0,0 +1,137 @@ +From 262e34b54bccc90cd514cb253d823b6a2cbcc16f 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 cfc3b26..e378d69 100644 +--- a/src/pip/_internal/operations/install/wheel.py ++++ b/src/pip/_internal/operations/install/wheel.py +@@ -402,17 +402,37 @@ 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): + def make( + self, specification: str, options: Optional[Dict[str, Any]] = 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 7b44a59..4ad371a 100644 +--- a/tests/unit/test_wheel.py ++++ b/tests/unit/test_wheel.py +@@ -517,6 +517,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 = ( +@@ -720,3 +746,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 ca0366f..ece80ed 100644 --- a/python-pip.spec +++ b/python-pip.spec @@ -112,6 +112,14 @@ Patch: truststore-pem-path.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