Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ceff283
Backport CVE-2023-5752: prevent option injection via Mercurial revision
icanhasmath Oct 21, 2026
1ce8f44
Backport CVE-2026-1703: use os.path.commonpath in is_within_directory
icanhasmath Oct 21, 2026
dff2368
Backport CVE-2025-8869: validate tar symlink targets during extraction
icanhasmath Oct 21, 2026
5e44cc3
Backport CVE-2026-3219: disambiguate tar/zip archive detection in unp…
icanhasmath Oct 21, 2026
1dd6590
Backport CVE-2026-8643: reject entry point names that escape the scri…
icanhasmath Oct 21, 2026
afe0929
Backport CVE-2026-13346: stop Link.filename decoding the URL path twice
icanhasmath Oct 21, 2026
fd30acd
Backport CVE-2026-6357: run the self-version check before installing
icanhasmath Oct 21, 2026
e65adb4
Upgrade vendored certifi to 2026.10.19.post1
icanhasmath Oct 21, 2026
35fd99d
Release 21.3.1.post1: backport seven pip CVEs; vendored certifi 2026.…
icanhasmath Oct 21, 2026
61f744b
Review: harden CVE-2025-8869 fix against symlink traversal (pypa/pip#…
icanhasmath Oct 22, 2026
65f3121
Review: root-safe path containment for is_within_directory and _scrip…
icanhasmath Oct 22, 2026
1f35dbc
Review: pass Mercurial revisions as --rev=<rev> (pypa/pip#12373)
icanhasmath Oct 22, 2026
fb2e14d
Review: run the pre-command version check inside pip's error handling
icanhasmath Oct 22, 2026
960bde6
Review: accept normalized member names in is_symlink_target_in_tar
icanhasmath Oct 22, 2026
188d999
Review: report unsafe download file names as a pip error, not a trace…
icanhasmath Oct 22, 2026
440ee95
Review: NEWS — render notes, record review fixes
icanhasmath Oct 22, 2026
e710286
Review: stop _tar_member_paths at a fixed point
icanhasmath Oct 22, 2026
ac20817
Review: let a symlink resolve to an explicit archive root
icanhasmath Oct 22, 2026
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
71 changes: 71 additions & 0 deletions NEWS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,77 @@

.. towncrier release notes start

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

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

- **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>`_)
- **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.3.3 already limits entry point names to
``[\w.+-]+``, so only ``.`` and ``..`` reached the scripts directory join on
this release; the check is backported as defense in depth. (`GHSA-wf93-45jw-7689 <https://github.com/advisories/GHSA-wf93-45jw-7689>`_)
- **CVE-2026-6357**: Run the pip self-version check before the command body
and only print its upgrade prompt afterwards, so pip no longer queries the
environment (and probes ``import _manylinux`` via ``packaging.tags``) after
``pip install`` has written new packages; a wheel shipping a top-level
``_manylinux.py`` could otherwise get it executed by pip. (`GHSA-jp4c-xjxw-mgf9 <https://github.com/advisories/GHSA-jp4c-xjxw-mgf9>`_)
- **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-1703**: Compare paths on a component boundary in
``is_within_directory`` (instead of a character-prefix comparison) so an
extracted file cannot land in a sibling directory whose name is a prefix of
the install path. (`GHSA-6vgw-5pg2-w6jp <https://github.com/advisories/GHSA-6vgw-5pg2-w6jp>`_)
- **CVE-2025-8869**: Validate that tar symlink members point to other members
inside the same archive before extraction, and harden the fix as upstream
did in pypa/pip#14127: every member and every symlink target is also checked
with symlinks resolved, so a chain such as ``link -> ..`` followed by
``link/file`` (or ``redir -> .``, ``redir/up -> ..``) can no longer write
outside the extract directory. Python 3.6 has no ``tarfile.data_filter``, so
this is the only protection on this release. (`GHSA-4xh5-x5gv-qwph <https://github.com/advisories/GHSA-4xh5-x5gv-qwph>`_)
- **CVE-2023-5752**: Pass a Mercurial revision as a single ``--rev=<rev>``
token so a malicious revision cannot inject ``hg`` command-line options (e.g.
``--config``). The long form is used because ``hg`` rejects ``-r=<rev>``
(pypa/pip#12373). (`GHSA-mq26-g339-26xf <https://github.com/advisories/GHSA-mq26-g339-26xf>`_)
- **CVE-2022-23491** / **CVE-2023-37920** / **CVE-2024-39689** /
**GHSA-6q48-6c7w-9gw2**: Upgrade vendored certifi from 2021.05.30 to
2026.10.19.post1 (ActiveState's build of certifi 2026.10.19, the release
vendored by pip 26.2.2, with a fallback for Python 3.6), dropping the
distrusted TrustCor, E-Tugra, GLOBALTRUST and emSign ECC Root CA - G3 root
certificates. (`GHSA-43fp-rhv2-5gv8 <https://github.com/advisories/GHSA-43fp-rhv2-5gv8>`_,
`GHSA-xqr8-7jwr-rhp7 <https://github.com/advisories/GHSA-xqr8-7jwr-rhp7>`_,
`GHSA-248v-346w-9cwc <https://github.com/advisories/GHSA-248v-346w-9cwc>`_,
`GHSA-6q48-6c7w-9gw2 <https://github.com/advisories/GHSA-6q48-6c7w-9gw2>`_)

Bug Fixes
---------

- Path containment checks (``is_within_directory`` and the entry point
scripts-directory check) are root-safe: installing under a POSIX ``//``
prefix works again, a scripts directory at a filesystem root (``/`` or
``C:\``) no longer rejects every entry point, and a target on another
Windows drive is refused without a traceback.
- The pre-command pip version check runs inside pip's error handling, so a
malformed requirement or Ctrl-C during the check is reported like any other
command error.
- Tar symlinks are matched against normalized member names (and implied parent
directories), so ``./``-prefixed archives and links to a directory without
its own entry unpack again.
- A download whose file name is not a single safe path component is reported
as a normal ``ERROR:`` line instead of a traceback.

.. note::

This release uses the PEP 440 post-release scheme (``21.3.1.postN``).


21.3.1 (2021-10-22)
===================

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 typing import List, Optional

__version__ = "21.3.1"
__version__ = "21.3.1.post1"


def main(args: Optional[List[str]] = None) -> int:
Expand Down
29 changes: 19 additions & 10 deletions src/pip/_internal/cli/base_command.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Base Command class, and related routines"""

import contextlib
import functools
import logging
import logging.config
Expand All @@ -8,7 +9,7 @@
import sys
import traceback
from optparse import Values
from typing import Any, Callable, List, Optional, Tuple
from typing import Any, Callable, Iterator, List, Optional, Tuple

from pip._internal.cli import cmdoptions
from pip._internal.cli.command_context import CommandContextMixIn
Expand Down Expand Up @@ -76,14 +77,16 @@ 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
Expand Down Expand Up @@ -204,11 +207,17 @@ def exc_logging_wrapper(*args: Any) -> int:

return exc_logging_wrapper

try:
if not options.debug_mode:
run = intercepts_unhandled_exc(self.run)
else:
run = self.run
return run(options, args)
finally:
self.handle_pip_version_check(options)
def _inner_run(options: Values, args: List[str]) -> int:
# The version check fetches everything it needs before the command
# body runs, and only prints its prompt afterwards, so nothing is
# imported after packages have been installed (CVE-2026-6357). It
# runs inside the error handling below, so a failure or Ctrl-C
# during the fetch is reported like any other command error.
with self.pip_version_check(options, args):
return self.run(options, args)

if not options.debug_mode:
run = intercepts_unhandled_exc(_inner_run)
else:
run = _inner_run
return run(options, args)
46 changes: 36 additions & 10 deletions src/pip/_internal/cli/req_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
PackageFinder machinery and all its vendored dependencies, etc.
"""

import contextlib
import logging
import os
import sys
from functools import partial
from optparse import Values
from typing import Any, List, Optional, Tuple
from typing import Any, Iterator, List, Optional, Tuple

from pip._internal.cache import WheelCache
from pip._internal.cli import cmdoptions
Expand All @@ -33,7 +34,11 @@
from pip._internal.req.req_install import InstallRequirement
from pip._internal.req.req_tracker import RequirementTracker
from pip._internal.resolution.base import BaseResolver
from pip._internal.self_outdated_check import pip_self_version_check
from pip._internal.self_outdated_check import (
UpgradePrompt,
pip_self_version_check_emit,
pip_self_version_check_fetch,
)
from pip._internal.utils.deprecation import deprecated
from pip._internal.utils.temp_dir import (
TempDirectory,
Expand Down Expand Up @@ -128,24 +133,45 @@ class IndexGroupCommand(Command, SessionCommandMixin):
This also corresponds to the commands that permit the pip version check.
"""

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.

This overrides the default behavior of not doing the check.
This overrides the default behavior of not doing the check. The
remote version is fetched before the command body runs and the
upgrade prompt is only emitted afterwards, so the check never imports
modules after packages have been installed (CVE-2026-6357).
"""
# Make sure the index_group options are present.
assert hasattr(options, "no_index")

if options.disable_pip_version_check or options.no_index:
yield
return

# 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: Optional[UpgradePrompt] = 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:
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)


KEEPABLE_TEMPDIR_TYPES = [
Expand Down
26 changes: 25 additions & 1 deletion src/pip/_internal/commands/install.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import contextlib
import errno
import operator
import os
import shutil
import site
from optparse import SUPPRESS_HELP, Values
from typing import Iterable, List, Optional
from typing import Iterable, Iterator, List, Optional

from pip._vendor.packaging.requirements import InvalidRequirement, Requirement
from pip._vendor.packaging.utils import canonicalize_name

from pip._internal.cache import WheelCache
Expand Down Expand Up @@ -58,6 +60,17 @@ def check_binary_allowed(req: InstallRequirement) -> bool:
return check_binary_allowed


def _arg_refers_to_pip(arg: str) -> bool:
try:
req = Requirement(arg)
except (InvalidRequirement, ValueError):
# The vendored packaging can raise a bare ValueError from URL parsing
# (e.g. "foo @ http://[bad/x"); leave it to the normal requirement
# parser to report.
return False
return canonicalize_name(req.name) == "pip"


class InstallCommand(RequirementCommand):
"""
Install packages from:
Expand Down Expand Up @@ -237,6 +250,17 @@ def add_options(self) -> None:
self.parser.insert_option_group(0, index_opts)
self.parser.insert_option_group(0, self.cmd_opts)

@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
4 changes: 4 additions & 0 deletions src/pip/_internal/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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 UninstallationError(PipError):
"""General exception during uninstallation"""

Expand Down
79 changes: 66 additions & 13 deletions src/pip/_internal/models/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,18 @@
import posixpath
import re
import urllib.parse
from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Tuple, Union
from typing import (
TYPE_CHECKING,
Dict,
List,
NamedTuple,
NewType,
Optional,
Tuple,
Union,
)

from pip._internal.exceptions import UnsafeFilenameError
from pip._internal.utils.filetypes import WHEEL_EXTENSION
from pip._internal.utils.hashes import Hashes
from pip._internal.utils.misc import (
Expand All @@ -22,6 +32,52 @@
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``.
``NewType`` is only enforced by the type checker, so the guarantee is also
checked at runtime here.
"""
if not component or component != _to_path_component(component):
raise UnsafeFilenameError(f"Not a single path component: {component!r}")

return os.path.join(directory, component)


_SUPPORTED_HASHES = ("sha1", "sha224", "sha384", "sha256", "sha512", "md5")


Expand Down Expand Up @@ -103,18 +159,15 @@ def url(self) -> str:
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:
# self.path is already percent-decoded; decoding it again would turn a
# doubly-encoded separator (%252F) into a real "/" (CVE-2026-13346).
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