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
62 changes: 60 additions & 2 deletions NEWS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,62 @@

.. towncrier release notes start

20.3.4.post3 (2026-10-21)
=========================

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

- **CVE-2026-6357**: Run the pip self-version check before the command body
and only report its result afterwards, so ``pip install`` can no longer be
made to import and execute a module from a just-installed wheel. The check
computes supported tags, and the vendored ``packaging`` probes
``import _manylinux`` (the PEP 513/600 override hook) every time it does.
(`GHSA-jp4c-xjxw-mgf9 <https://github.com/advisories/GHSA-jp4c-xjxw-mgf9>`_)
- **CVE-2025-8869** (hardening): Port upstream's follow-up fix (pypa/pip#14127)
to the tar extraction path, which is the only one on Python 2.7 (there is no
``tarfile.data_filter``). A member is now rejected unless its resolved
(``realpath``) destination stays inside the extract directory, and a symlink
is rejected when its resolved target leaves it. Previously a symlink to
``.`` or ``..`` (both archive members) let a later member be written outside
the extract directory. 20.3.4+security.1 and 20.3.4.post2 are affected.
(`GHSA-4xh5-x5gv-qwph <https://github.com/advisories/GHSA-4xh5-x5gv-qwph>`_)
- **CVE-2023-5752** (regression fix): Pass a Mercurial revision as
``--rev=<rev>`` instead of ``-r=<rev>`` (pypa/pip#12373). Mercurial parses
``-r=<rev>`` as the revision ``=<rev>`` and fails, so every
``hg+...@<rev>`` install has been broken since 20.3.4+security.1 (also in
20.3.4.post2). The revision is still a single token, so the CVE protection
is unchanged.
(`GHSA-mq26-g339-26xf <https://github.com/advisories/GHSA-mq26-g339-26xf>`_)

Bug Fixes
---------

- ``is_within_directory`` and the CVE-2026-8643 entry point check accept a
directory that is a filesystem root (``/`` or ``C:\``); previously every
target, and every entry point, was rejected there.
- The pre-command version-check lookup now runs inside pip's error handling,
so Ctrl-C during it reports "Operation cancelled by user", and a malformed
requirement such as ``foo @ http://[bad/x`` gets pip's normal error report
instead of a raw traceback.
- Symlinks in an sdist are matched against normalized member names, including
implied parent directories, so ``./``-prefixed archives (``tar -C dir .``)
and links to a directory without its own entry unpack again. Containment is
still enforced by the ``realpath`` checks above.
- A download whose file name is not a single safe path component (e.g.
``Content-Disposition: filename=".."``) is reported as a pip ``ERROR:``
line instead of a traceback. It was already refused.

.. note::

This corrects the 20.3.4+security.1 notes, which assessed CVE-2026-6357
as **not applicable** to 20.3.4. That was wrong: a failed import is never
cached in ``sys.modules``, so the ``_manylinux`` probe is retried by the
post-install check and picks up a top-level ``_manylinux.py`` that a wheel
has just installed. 20.3.4+security.1 and 20.3.4.post2 are affected;
``--disable-pip-version-check`` avoids it on those releases.


20.3.4.post2 (2026-10-21)
=========================

Expand All @@ -33,7 +89,7 @@ Security Fixes
(`GHSA-43fp-rhv2-5gv8 <https://github.com/advisories/GHSA-43fp-rhv2-5gv8>`_,
`GHSA-xqr8-7jwr-rhp7 <https://github.com/advisories/GHSA-xqr8-7jwr-rhp7>`_)

.. note
.. note::

CVE-2024-39689 / GHSA-248v-346w-9cwc (certifi GLOBALTRUST roots) was
assessed and found **not applicable**: those roots were added in certifi
Expand Down Expand Up @@ -67,14 +123,16 @@ Security Fixes
(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>`_)

.. note
.. note::

CVE-2026-6357 / GHSA-jp4c-xjxw-mgf9 was assessed and found **not applicable**
to this release: pip 20.3.4 imports the self-version-check and all of its
dependencies eagerly at startup, and the post-install check touches only
vendored (``pip._vendor.*``) modules, so a newly-installed wheel has no
well-known module name to shadow.

(Corrected in 20.3.4.post3: this release *is* affected, see above.)


20.3.4 (2021-01-23)
===================
Expand Down
2 changes: 1 addition & 1 deletion src/pip/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import List, Optional


__version__ = "20.3.4.post2"
__version__ = "20.3.4.post3"


def main(args=None):
Expand Down
27 changes: 26 additions & 1 deletion src/pip/_internal/cli/base_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,25 @@ def handle_pip_version_check(self, options):
# are present.
assert not hasattr(options, 'no_index')

def _pip_version_check_fetch(self, options, args):
# type: (Values, List[str]) -> Any
"""Gather what the pip version check needs, before the command runs.

The result is passed to _pip_version_check_emit() once the command
body has finished. Doing the lookups up front means nothing is
imported after packages have been installed (CVE-2026-6357).
"""
return None

def _pip_version_check_emit(self, options, fetched):
# type: (Values, Any) -> None
"""Report the pip version check after the command has run.

By default this calls handle_pip_version_check(), which is a no-op
for commands without the index_group options.
"""
self.handle_pip_version_check(options)

def run(self, options, args):
# type: (Values, List[Any]) -> int
raise NotImplementedError
Expand Down Expand Up @@ -219,7 +238,13 @@ def _main(self, args):
"This will become an error in pip 21.0."
)

# The version check does its lookups before the command body runs and
# only reports afterwards, so nothing is imported once packages have
# been installed (CVE-2026-6357). The fetch runs inside the try so
# its errors (and Ctrl-C) get pip's normal error handling.
version_check = None
try:
version_check = self._pip_version_check_fetch(options, args)
status = self.run(options, args)
assert isinstance(status, int)
return status
Expand Down Expand Up @@ -257,4 +282,4 @@ def _main(self, args):

return UNKNOWN_ERROR
finally:
self.handle_pip_version_check(options)
self._pip_version_check_emit(options, version_check)
43 changes: 42 additions & 1 deletion src/pip/_internal/cli/req_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@
install_req_from_req_string,
)
from pip._internal.req.req_file import parse_requirements
from pip._internal.self_outdated_check import pip_self_version_check
from pip._internal.self_outdated_check import (
_pip_self_version_check_emit,
_pip_self_version_check_fetch,
pip_self_version_check,
)
from pip._internal.utils.temp_dir import tempdir_kinds
from pip._internal.utils.typing import MYPY_CHECK_RUNNING

Expand Down Expand Up @@ -152,6 +156,43 @@ def handle_pip_version_check(self, options):
with session:
pip_self_version_check(session, options)

def _pip_version_check_fetch(self, options, args):
# type: (Values, List[str]) -> Any
"""Query the index for the latest pip before the command body runs.

handle_pip_version_check() does the same check in one step, but run
after `pip install` it would compute supported tags, which probes
``import _manylinux``, and could import a module that a just-installed
wheel provided (CVE-2026-6357). So _main() fetches here and only
reports in _pip_version_check_emit().
"""
# Make sure the index_group options are present.
assert hasattr(options, 'no_index')

if options.disable_pip_version_check or options.no_index:
return None

try:
session = self._build_session(
options,
retries=0,
timeout=min(5, options.timeout)
)
with session:
return _pip_self_version_check_fetch(session, options)
except Exception:
logger.debug(
"There was an error checking the latest version of pip",
exc_info=True,
)
return None

def _pip_version_check_emit(self, options, fetched):
# type: (Values, Any) -> None
# Only report what was fetched before the command ran. Do not fall
# back to handle_pip_version_check(), which would query again now.
_pip_self_version_check_emit(fetched)


KEEPABLE_TEMPDIR_TYPES = [
tempdir_kinds.BUILD_ENV,
Expand Down
25 changes: 24 additions & 1 deletion src/pip/_internal/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from optparse import SUPPRESS_HELP

from pip._vendor import pkg_resources
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 @@ -37,7 +38,7 @@

if MYPY_CHECK_RUNNING:
from optparse import Values
from typing import Iterable, List, Optional
from typing import Any, Iterable, List, Optional

from pip._internal.models.format_control import FormatControl
from pip._internal.operations.check import ConflictDetails
Expand All @@ -61,6 +62,17 @@ def check_binary_allowed(req):
return check_binary_allowed


def _arg_refers_to_pip(arg):
# type: (str) -> bool
try:
req = Requirement(arg)
except (InvalidRequirement, ValueError):
# A malformed requirement (e.g. a URL that urlparse rejects with
# ValueError) is not pip; leave the error to the normal parsing path.
return False
return canonicalize_name(req.name) == "pip"


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

def _pip_version_check_fetch(self, options, args):
# type: (Values, List[str]) -> Any
# 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):
return None
return super(InstallCommand, self)._pip_version_check_fetch(
options, args
)

@with_cleanup
def run(self, options, args):
# type: (Values, List[str]) -> int
Expand Down
10 changes: 10 additions & 0 deletions src/pip/_internal/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ 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.

It is also a ValueError, so callers written against the plain
ValueError keep working, while pip's command loop reports it as an
ordinary ``ERROR:`` line instead of a traceback.
"""


class UninstallationError(PipError):
"""General exception during uninstallation"""

Expand Down
5 changes: 3 additions & 2 deletions src/pip/_internal/models/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from pip._vendor.six.moves.urllib import parse as urllib_parse

from pip._internal.exceptions import UnsafeFilenameError
from pip._internal.utils.filetypes import WHEEL_EXTENSION
from pip._internal.utils.misc import (
redact_auth_from_url,
Expand Down Expand Up @@ -44,7 +45,7 @@ def as_path_component(name):
"""
component = _to_path_component(name)
if not component:
raise ValueError(
raise UnsafeFilenameError(
"Unexpected file name derived from URL: {!r}".format(name)
)

Expand All @@ -61,7 +62,7 @@ def join_within_directory(directory, component):
has no runtime type to enforce that, so it is checked here instead.
"""
if not component or component != _to_path_component(component):
raise ValueError(
raise UnsafeFilenameError(
"Not a single path component: {!r}".format(component)
)

Expand Down
4 changes: 3 additions & 1 deletion src/pip/_internal/operations/install/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,9 @@ def _script_within_dir(name, scripts_dir):
"""
root = os.path.normpath(scripts_dir)
dest = os.path.normpath(os.path.join(scripts_dir, name))
return dest.startswith(root + os.sep)
# The script must be a file *inside* the directory, never the directory
# itself.
return dest != root and is_within_directory(root, dest)


def _raise_for_invalid_entrypoint(specification, scripts_dir):
Expand Down
Loading
Loading