Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
350 changes: 350 additions & 0 deletions SPECS/python-pip/CVE-2026-13346.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,350 @@
From fa97aac9c5d8937ef78fca463000695dc48f4dda Mon Sep 17 00:00:00 2001
From: AllSpark <allspark@microsoft.com>
Date: Mon, 3 Aug 2026 05:52:48 +0000
Subject: [PATCH] Fix Link.filename decoding URL path twice

Signed-off-by: Azure Linux Security Servicing Account <azurelinux-security@microsoft.com>
Upstream-reference: AI Backport of https://github.com/pypa/pip/commit/10dfb6b9005484578b386f64b9f36982e3dc6679.patch
---
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, 181 insertions(+), 22 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 0000000..f7d4f78
--- /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 2f41f2f..a3e7f0f 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,
@@ -35,6 +36,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")
@@ -391,18 +435,13 @@ class Link:
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 5c3bce3..e2d1e99 100644
--- a/src/pip/_internal/network/download.py
+++ b/src/pip/_internal/network/download.py
@@ -12,7 +12,12 @@ from pip._vendor.requests.models import 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
@@ -92,11 +97,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:
@@ -110,7 +118,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:
@@ -141,7 +149,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:
@@ -177,7 +185,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 e6aa344..bfd493b 100644
--- a/src/pip/_internal/operations/prepare.py
+++ b/src/pip/_internal/operations/prepare.py
@@ -27,7 +27,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 (
@@ -191,7 +191,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
@@ -668,7 +668,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..98bb924 100644
--- a/tests/unit/test_link.py
+++ b/tests/unit/test_link.py
@@ -1,8 +1,16 @@
from typing import Optional

+import os
+import posixpath
+
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 +36,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 +63,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 +301,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.45.4

6 changes: 5 additions & 1 deletion SPECS/python-pip/python-pip.spec
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ A tool for installing and managing Python packages}
Summary: A tool for installing and managing Python packages
Name: python-pip
Version: 24.2
Release: 9%{?dist}
Release: 10%{?dist}
License: MIT AND Python-2.0.1 AND Apache-2.0 AND BSD-2-Clause AND BSD-3-Clause AND ISC AND LGPL-2.1-only AND MPL-2.0 AND (Apache-2.0 OR BSD-2-Clause)
Vendor: Microsoft Corporation
Distribution: Azure Linux
Expand All @@ -19,6 +19,7 @@ Patch3: CVE-2026-1703.patch
Patch4: CVE-2026-3219.patch
Patch5: CVE-2026-6357.patch
Patch6: CVE-2026-8643.patch
Patch7: CVE-2026-13346.patch

BuildArch: noarch

Expand Down Expand Up @@ -62,6 +63,9 @@ BuildRequires: python3-wheel
%{python3_sitelib}/pip*

%changelog
* Mon Aug 03 2026 Azure Linux Security Servicing Account <azurelinux-security@microsoft.com> - 24.2-10
- Patch for CVE-2026-13346

* Wed Jun 03 2026 Azure Linux Security Servicing Account <azurelinux-security@microsoft.com> - 24.2-9
- Patch for CVE-2026-8643

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@ python3-magic-5.45-1.azl3.noarch.rpm
python3-markupsafe-2.1.3-1.azl3.aarch64.rpm
python3-newt-0.52.23-1.azl3.aarch64.rpm
python3-packaging-23.2-3.azl3.noarch.rpm
python3-pip-24.2-9.azl3.noarch.rpm
python3-pip-24.2-10.azl3.noarch.rpm
python3-pygments-2.7.4-2.azl3.noarch.rpm
python3-rpm-4.18.2-1.azl3.aarch64.rpm
python3-rpm-generators-14-11.azl3.noarch.rpm
Expand Down
2 changes: 1 addition & 1 deletion toolkit/resources/manifests/package/toolchain_x86_64.txt
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ python3-magic-5.45-1.azl3.noarch.rpm
python3-markupsafe-2.1.3-1.azl3.x86_64.rpm
python3-newt-0.52.23-1.azl3.x86_64.rpm
python3-packaging-23.2-3.azl3.noarch.rpm
python3-pip-24.2-9.azl3.noarch.rpm
python3-pip-24.2-10.azl3.noarch.rpm
python3-pygments-2.7.4-2.azl3.noarch.rpm
python3-rpm-4.18.2-1.azl3.x86_64.rpm
python3-rpm-generators-14-11.azl3.noarch.rpm
Expand Down
Loading