Skip to content
Merged
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
64 changes: 64 additions & 0 deletions NEWS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,70 @@

.. towncrier release notes start

26.0.1.post1 (2026-10-21)
=========================

Security Fixes
--------------

- **CVE-2026-3219**: Disambiguate archive type detection in ``unpack_file``
so a tar+zip polyglot is no longer always treated as a zip; ambiguous
archives are rejected. (`GHSA-58qw-9mgm-455v <https://github.com/advisories/GHSA-58qw-9mgm-455v>`_)
- **CVE-2026-6357**: Run the network and import side of the pip
self-version check before the command body, so a just-installed wheel
cannot shadow a module the check imports afterwards; only the upgrade
prompt is printed after the command. (`GHSA-jp4c-xjxw-mgf9 <https://github.com/advisories/GHSA-jp4c-xjxw-mgf9>`_)
- **CVE-2026-8643**: Reject ``console_scripts`` and ``gui_scripts`` entry
points whose name would install a script outside the scripts directory.
The vendored distlib 0.4.0 accepts such names unchanged, so this release
was directly affected. (`GHSA-wf93-45jw-7689 <https://github.com/advisories/GHSA-wf93-45jw-7689>`_)
- **CVE-2026-13346**: Stop ``Link.filename`` percent-decoding the URL path a
second time, so a doubly-encoded separator from a malicious index (e.g.
``..%252F..%252Fevil.whl``) can no longer write a download outside the
download directory. Download file names are now always validated as a
single path component. (`GHSA-qwm4-qh6w-59xr <https://github.com/advisories/GHSA-qwm4-qh6w-59xr>`_)
- **GHSA-6q48-6c7w-9gw2**: Upgrade vendored certifi from 2026.1.4 to
2026.10.19 (the release vendored by pip 26.2.2), dropping the emSign ECC
Root CA - G3, whose intermediate CA keys were compromised. (`GHSA-6q48-6c7w-9gw2 <https://github.com/advisories/GHSA-6q48-6c7w-9gw2>`_)
- **CVE-2025-8869**: Harden the tar extraction fallback (used when
``tarfile.data_filter`` is unavailable, i.e. Python 3.9.0-3.9.16), porting
pypa/pip#14127: members are also checked after resolving symlinks, and a
symlink that resolves outside the destination is rejected before it is
created. Previously a symlink to ``.`` or ``..`` let a later member be
written outside the extract directory. (`GHSA-4xh5-x5gv-qwph <https://github.com/advisories/GHSA-4xh5-x5gv-qwph>`_)

Bug Fixes
---------

- ``is_within_directory`` no longer uses ``os.path.commonpath``, so installs
under a POSIX ``//``-prefixed path (e.g. ``--prefix //tmp/x``) work again,
and a directory at a filesystem root (``/`` or ``C:\``) is handled; a
target on another Windows drive is reported as outside instead of raising.
- A scripts directory at a filesystem root no longer rejects every console
or GUI script (the CVE-2026-8643 check now uses ``is_within_directory``).
- The tar fallback's symlink check compares normalized member names, so
``./``-prefixed archives and links to a directory that has no entry of its
own unpack again; containment is still enforced by the symlink-resolving
checks above.
- An unsafe download file name (e.g. ``Content-Disposition: filename=".."``)
is reported as a pip ``ERROR:`` instead of a ``ValueError`` traceback.
- The ``pip install pip`` detection used by the self-version check no longer
fails on a requirement that makes packaging raise a bare ``ValueError``.

.. note::

CVE-2026-1703 / GHSA-6vgw-5pg2-w6jp (``is_within_directory`` prefix
comparison) was already fixed upstream before pip 26.0.1.
CVE-2025-8869 / GHSA-4xh5-x5gv-qwph (tar symlink targets on the
extraction fallback) was fixed in 26.0.1 and is hardened in this release
(see above).

This is an ActiveState security release for Python 3.9, the last Python
version pip 26.0.x supports (pip 26.1 requires Python 3.10). It uses the
PEP 440 post-release scheme: ``26.0.1.post1`` sorts after ``26.0.1`` and
before ``26.0.2``.


26.0.1 (2026-02-04)
===================

Expand Down
2 changes: 1 addition & 1 deletion src/pip/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

__version__ = "26.0.1"
__version__ = "26.0.1.post1"


def main(args: list[str] | None = None) -> int:
Expand Down
10 changes: 6 additions & 4 deletions src/pip/_internal/cli/base_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

from __future__ import annotations

import contextlib
import logging
import logging.config
import optparse
import os
import sys
import traceback
from collections.abc import Iterator
from optparse import Values
from typing import Callable

Expand Down Expand Up @@ -80,24 +82,24 @@ def __init__(self, name: str, summary: str, isolated: bool = False) -> None:
def add_options(self) -> None:
pass

def handle_pip_version_check(self, options: Values) -> None:
@contextlib.contextmanager
def pip_version_check(self, options: Values, args: list[str]) -> Iterator[None]:
"""
This is a no-op so that commands by default do not do the pip version
check.
"""
# Make sure we do the pip version check if the index_group options
# are present.
assert not hasattr(options, "no_index")
yield

def run(self, options: Values, args: list[str]) -> int:
raise NotImplementedError

def _run_wrapper(self, level_number: int, options: Values, args: list[str]) -> int:
def _inner_run() -> int:
try:
with self.pip_version_check(options, args):
return self.run(options, args)
finally:
self.handle_pip_version_check(options)

if options.debug_mode:
rich_traceback.install(show_locals=True)
Expand Down
34 changes: 28 additions & 6 deletions src/pip/_internal/cli/index_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@

from __future__ import annotations

import contextlib
import logging
import os
import sys
from collections.abc import Iterator
from functools import lru_cache
from optparse import Values
from typing import TYPE_CHECKING
Expand All @@ -26,6 +28,7 @@
from pip._vendor.packaging.utils import NormalizedName

from pip._internal.network.session import PipSession
from pip._internal.self_outdated_check import UpgradePrompt

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -139,10 +142,18 @@ def _build_session(
return session


def _pip_self_version_check(session: PipSession, options: Values) -> None:
from pip._internal.self_outdated_check import pip_self_version_check as check
def _pip_self_version_check_fetch(
session: PipSession, options: Values
) -> UpgradePrompt | None:
from pip._internal.self_outdated_check import pip_self_version_check_fetch

check(session, options)
return pip_self_version_check_fetch(session, options)


def _pip_self_version_check_emit(upgrade_prompt: UpgradePrompt | None) -> None:
from pip._internal.self_outdated_check import pip_self_version_check_emit

pip_self_version_check_emit(upgrade_prompt)


class IndexGroupCommand(Command, SessionCommandMixin):
Expand All @@ -169,7 +180,8 @@ def should_exclude_prerelease(
# No specific setting: exclude prereleases by default
return True

def handle_pip_version_check(self, options: Values) -> None:
@contextlib.contextmanager
def pip_version_check(self, options: Values, args: list[str]) -> Iterator[None]:
"""
Do the pip version check if not disabled.

Expand All @@ -179,17 +191,27 @@ def handle_pip_version_check(self, options: Values) -> None:
assert hasattr(options, "no_index")

if options.disable_pip_version_check or options.no_index:
yield
return

upgrade_prompt: UpgradePrompt | None = None
try:
# Otherwise, check if we're using the latest version of pip available.
session = self._build_session(
options,
retries=0,
timeout=min(5, options.timeout),
)
with session:
_pip_self_version_check(session, options)
upgrade_prompt = _pip_self_version_check_fetch(session, options)
except Exception:
logger.warning("There was an error checking the latest version of pip.")
logger.debug("See below for error", exc_info=True)

try:
yield
finally:
try:
_pip_self_version_check_emit(upgrade_prompt)
except Exception:
logger.warning("There was an error checking the latest version of pip.")
logger.debug("See below for error", exc_info=True)
24 changes: 24 additions & 0 deletions src/pip/_internal/commands/install.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
from __future__ import annotations

import contextlib
import errno
import json
import operator
import os
import shutil
import site
from collections.abc import Iterator
from optparse import SUPPRESS_HELP, Values
from pathlib import Path

from pip._vendor.packaging.requirements import InvalidRequirement, Requirement
from pip._vendor.packaging.utils import canonicalize_name
from pip._vendor.requests.exceptions import InvalidProxyURL
from pip._vendor.rich import print_json
Expand Down Expand Up @@ -63,6 +66,16 @@
logger = getLogger(__name__)


def _arg_refers_to_pip(arg: str) -> bool:
try:
req = Requirement(arg)
except (InvalidRequirement, ValueError):
# Older packaging can surface a bare ValueError (e.g. from urlsplit on
# "foo @ http://[bad"); let pip's normal requirement parsing report it.
return False
return canonicalize_name(req.name) == "pip"


class InstallCommand(RequirementCommand):
"""
Install packages from:
Expand Down Expand Up @@ -278,6 +291,17 @@ def add_options(self) -> None:
),
)

@contextlib.contextmanager
def pip_version_check(self, options: Values, args: list[str]) -> Iterator[None]:
# Skip the self-version check when pip itself is a requirement. The
# running pip may be replaced mid-command, and the upgrade prompt
# is redundant.
if any(_arg_refers_to_pip(arg) for arg in args):
yield
return
with super().pip_version_check(options, args):
yield

@with_cleanup
def run(self, options: Values, args: list[str]) -> int:
if options.use_user_site and options.target_dir is not None:
Expand Down
13 changes: 9 additions & 4 deletions src/pip/_internal/commands/list.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from __future__ import annotations

import contextlib
import json
import logging
from collections.abc import Generator, Sequence
from collections.abc import Generator, Iterator, Sequence
from email.parser import Parser
from optparse import Values
from typing import TYPE_CHECKING, cast
Expand Down Expand Up @@ -135,9 +136,13 @@ def add_options(self) -> None:
self.parser.insert_option_group(0, selection_opts)
self.parser.insert_option_group(0, self.cmd_opts)

def handle_pip_version_check(self, options: Values) -> None:
if options.outdated or options.uptodate:
super().handle_pip_version_check(options)
@contextlib.contextmanager
def pip_version_check(self, options: Values, args: list[str]) -> Iterator[None]:
if not (options.outdated or options.uptodate):
yield
return
with super().pip_version_check(options, args):
yield

def _build_package_finder(
self, options: Values, session: PipSession
Expand Down
4 changes: 4 additions & 0 deletions src/pip/_internal/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ class InstallationError(PipError):
"""General exception during installation"""


class UnsafeFilenameError(InstallationError, ValueError):
"""A file name derived from a URL or header is not a single safe path component."""


class FailedToPrepareCandidate(InstallationError):
"""Raised when we fail to prepare a candidate (i.e. fetch and generate metadata).

Expand Down
65 changes: 52 additions & 13 deletions src/pip/_internal/models/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
from typing import (
Any,
NamedTuple,
NewType,
)

from pip._internal.exceptions import InvalidEggFragment
from pip._internal.exceptions import InvalidEggFragment, UnsafeFilenameError
from pip._internal.utils.datetime import parse_iso_datetime
from pip._internal.utils.filetypes import WHEEL_EXTENSION
from pip._internal.utils.hashes import Hashes
Expand All @@ -31,6 +32,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 UnsafeFilenameError(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")
Expand Down Expand Up @@ -423,18 +467,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:
Expand Down
Loading
Loading