python3-pip: Fix CVE-2026-13346

This patch applies the upstream fix as referenced in
[2], using the commit shown in [1].

[1] https://github.com/pypa/pip/commit/10dfb6b90054
[2] https://nvd.nist.gov/vuln/detail/CVE-2026-13346

(From OE-Core rev: e0dde773c4329bc1d44e57ac67151cca388f19f7)

Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
Signed-off-by: Yoann Congal <yoann.congal@smile.fr>
Signed-off-by: Paul Barker <paul@pbarker.dev>
This commit is contained in:
Hetvi Thakar authored and Paul Barker committed 2026-09-28 10:17:42 +01:00
1 parent bf5c46b3f8
commit 84ab51789d
2 files changed
+224

No files matched your search

@@ -0,0 +1,223 @@
From e743e63ca083402fe0a344c4837cc6acf3081987 Mon Sep 17 00:00:00 2001
From: Damian Shaw <damian.peter.shaw@gmail.com>
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.
CVE: CVE-2026-13346
Upstream-Status: Backport [https://github.com/pypa/pip/commit/10dfb6b9005484578b386f64b9f36982e3dc6679]
Backport Changes:
- Adapted imports and download call sites to pip 24.0.
- Secured pip 24.0 separate BatchDownloader path.
- Omitted tests absent from the pip 24.0 source archive.
(cherry picked from commit 10dfb6b9005484578b386f64b9f36982e3dc6679)
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
---
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 +--
4 files changed, 69 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 000000000..f7d4f7888
--- /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 73041b864..e4bd559bf 100644
--- a/src/pip/_internal/models/link.py
+++ b/src/pip/_internal/models/link.py
@@ -13,6 +13,7 @@ from typing import (
List,
Mapping,
NamedTuple,
+ NewType,
Optional,
Tuple,
Union,
@@ -36,6 +37,49 @@ 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``.
+ 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")
@@ -380,18 +424,13 @@ class Link(KeyBasedCompareMixin):
return 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 d1d43541e..3aec97e84 100644
--- a/src/pip/_internal/network/download.py
+++ b/src/pip/_internal/network/download.py
@@ -11,7 +11,12 @@ from pip._vendor.requests.models import CONTENT_CHUNK_SIZE, Response
from pip._internal.cli.progress_bars import get_download_progress_renderer
from pip._internal.exceptions import 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
@@ -91,11 +96,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:
@@ -109,7 +117,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(session: PipSession, link: Link) -> Response:
@@ -140,7 +148,7 @@ class Downloader:
raise
filename = _get_http_response_filename(resp, link)
- filepath = os.path.join(location, filename)
+ filepath = join_within_directory(location, filename)
chunks = _prepare_download(resp, link, self._progress_bar)
with open(filepath, "wb") as content_file:
@@ -176,7 +184,7 @@ class BatchDownloader:
raise
filename = _get_http_response_filename(resp, link)
- filepath = os.path.join(location, filename)
+ filepath = join_within_directory(location, filename)
chunks = _prepare_download(resp, link, self._progress_bar)
with open(filepath, "wb") as content_file:
diff --git a/src/pip/_internal/operations/prepare.py b/src/pip/_internal/operations/prepare.py
index 956717d1e..0a9b39332 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 (
@@ -189,7 +189,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
@@ -666,7 +666,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)
--
2.35.6
@@ -36,6 +36,7 @@ SRC_URI += "file://no_shebang_mangling.patch \
file://CVE-2026-8643.patch \
file://CVE-2026-8643-regression_p1.patch \
file://CVE-2026-8643-regression_p2.patch \
file://CVE-2026-13346.patch \
"
SRC_URI[sha256sum] = "ea9bd1a847e8c5774a5777bb398c19e80bcd4e2aa16a4b301b718fe6f593aba2"