Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ee099cb
Backport CVE-2025-8869: validate tar symlink targets on the extractio…
icanhasmath Oct 21, 2026
5f10b28
Backport CVE-2026-1703: enforce component boundary in is_within_direc…
icanhasmath Oct 21, 2026
8451c00
Backport CVE-2026-3219: disambiguate tar/zip archive detection in unp…
icanhasmath Oct 21, 2026
57fcc05
Backport CVE-2026-6357: run the pip self-version check before the com…
icanhasmath Oct 21, 2026
7846755
Backport CVE-2026-8643: reject entry point names that escape the scri…
icanhasmath Oct 21, 2026
0286a28
Backport CVE-2026-13346: stop Link.filename decoding the URL path twice
icanhasmath Oct 21, 2026
64d7750
Upgrade vendored certifi to 2026.10.19
icanhasmath Oct 21, 2026
d647902
Release 25.0.1.post1: backport six pip CVEs; vendored certifi 2026.10.19
icanhasmath Oct 21, 2026
7df8664
Review: harden CVE-2025-8869 fix against symlink chains (pypa/pip#14127)
icanhasmath Oct 22, 2026
d1e5874
Review: root-safe path containment for is_within_directory and _scrip…
icanhasmath Oct 22, 2026
f8b51b8
Review: accept normalized member names in is_symlink_target_in_tar
icanhasmath Oct 22, 2026
393ba2c
Review: report unsafe download file names as a pip error, not a trace…
icanhasmath Oct 22, 2026
f830320
Review: treat a malformed requirement as "not pip" in _arg_refers_to_pip
icanhasmath Oct 22, 2026
8703b46
Review: NEWS - render notes, record review fixes
icanhasmath Oct 22, 2026
2f90327
Review: stop _tar_member_paths at a fixed point
icanhasmath Oct 22, 2026
914e01e
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
60 changes: 60 additions & 0 deletions NEWS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,66 @@

.. towncrier release notes start

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

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

- **CVE-2025-8869**: When ``tarfile.data_filter`` (PEP 706) is unavailable
(Python 3.8.0-3.8.16), validate that tar symlink members point to other
members inside the same archive before extraction, closing a symlink escape
on the fallback extraction path. The fix is hardened as in pypa/pip#14127:
every member and every symlink target must also resolve inside the
destination after following symlinks, so a chain such as
``link -> ..`` / ``link/file`` can no longer write outside it. (`GHSA-4xh5-x5gv-qwph <https://github.com/advisories/GHSA-4xh5-x5gv-qwph>`_)
- **CVE-2026-1703**: Compare paths on component boundaries in
``is_within_directory`` instead of with ``os.path.commonprefix``, 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-2026-3219**: Disambiguate archive type detection in ``unpack_file``:
Content-Type, then file extension, then magic signature, and only when
exactly one signature matches. 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 pip self-version check (and the imports it
needs) before the command body instead of after it, so modules installed by
``pip install`` are not imported later in the same pip invocation. The
check is skipped when pip itself is being installed. (`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.
(`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>`_)
- Upgrade vendored certifi from 2024.8.30 to 2026.10.19 (the release vendored
by pip 26.2.2), removing the emSign ECC Root CA - G3 whose intermediate CA
keys were compromised. (`GHSA-6q48-6c7w-9gw2 <https://github.com/advisories/GHSA-6q48-6c7w-9gw2>`_)

Bug Fixes
---------

- ``is_within_directory`` and the entry point scripts check now accept a
POSIX ``//``-prefixed path (``pip install --prefix //tmp/x`` no longer fails
with "trying to install outside the target directory") and a filesystem
root such as ``/`` as the containing directory. A target on another Windows
drive is rejected without raising.
- On the tar fallback path, a symlink whose target is an archive member under
a normalized name (a ``./``-prefixed archive made by ``tar -C dir .``) or an
implied directory with no entry of its own is no longer refused.
- A download whose file name reduces to ``.`` or ``..`` is reported as a pip
``ERROR:`` instead of a traceback (``UnsafeFilenameError``).
- The pre-command version check treats a malformed requirement as "not pip"
and leaves it to pip's normal requirement parsing to report.

.. note::

This is an ActiveState security release of pip 25.0.1, the last pip
release supporting Python 3.8. It uses the PEP 440 post-release scheme
(``25.0.1.postN``), so it sorts after ``25.0.1`` and before ``25.1``.


25.0.1 (2025-02-09)
===================

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__ = "25.0.1"
__version__ = "25.0.1.post1"


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

import contextlib
import logging
import logging.config
import optparse
import os
import sys
import traceback
from optparse import Values
from typing import List, Optional, Tuple
from typing import Iterator, List, Optional, Tuple

from pip._vendor.rich import reconfigure
from pip._vendor.rich import traceback as rich_traceback
Expand Down Expand Up @@ -79,24 +80,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
35 changes: 28 additions & 7 deletions src/pip/_internal/cli/index_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
--uptodate) don't need waste time importing PipSession and friends.
"""

import contextlib
import logging
import os
import sys
from optparse import Values
from typing import TYPE_CHECKING, List, Optional
from typing import TYPE_CHECKING, Iterator, List, Optional

from pip._vendor import certifi

Expand All @@ -21,6 +22,7 @@
from ssl import SSLContext

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

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -132,10 +134,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
) -> Optional["UpgradePrompt"]:
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: Optional["UpgradePrompt"]) -> 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 @@ -145,7 +155,8 @@ 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.

Expand All @@ -155,17 +166,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: 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:
_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)
25 changes: 24 additions & 1 deletion src/pip/_internal/commands/install.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import contextlib
import errno
import json
import operator
import os
import shutil
import site
from optparse import SUPPRESS_HELP, Values
from typing import List, Optional
from typing import Iterator, List, Optional

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

Expand Down Expand Up @@ -57,6 +59,16 @@
logger = getLogger(__name__)


def _arg_refers_to_pip(arg: str) -> bool:
try:
req = Requirement(arg)
except (InvalidRequirement, ValueError):
# A malformed requirement is not pip; leave it for pip's normal
# requirement parsing to report.
return False
return canonicalize_name(req.name) == "pip"


class InstallCommand(RequirementCommand):
"""
Install packages from:
Expand Down Expand Up @@ -270,6 +282,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
22 changes: 18 additions & 4 deletions src/pip/_internal/commands/list.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import contextlib
import json
import logging
from optparse import Values
from typing import TYPE_CHECKING, Generator, List, Optional, Sequence, Tuple, cast
from typing import (
TYPE_CHECKING,
Generator,
Iterator,
List,
Optional,
Sequence,
Tuple,
cast,
)

from pip._vendor.packaging.utils import canonicalize_name
from pip._vendor.packaging.version import Version
Expand Down Expand Up @@ -134,9 +144,13 @@ def add_options(self) -> None:
self.parser.insert_option_group(0, index_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
8 changes: 8 additions & 0 deletions src/pip/_internal/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,14 @@ class InvalidWheelFilename(InstallationError):
"""Invalid wheel filename."""


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 existing callers keep working, while pip's
command loop reports it as a normal ``ERROR:`` line instead of a traceback.
"""


class UnsupportedWheel(InstallationError):
"""Unsupported wheel."""

Expand Down
64 changes: 52 additions & 12 deletions src/pip/_internal/models/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@
List,
Mapping,
NamedTuple,
NewType,
Optional,
Tuple,
Union,
)

from pip._internal.exceptions import UnsafeFilenameError
from pip._internal.utils.deprecation import deprecated
from pip._internal.utils.filetypes import WHEEL_EXTENSION
from pip._internal.utils.hashes import Hashes
Expand All @@ -35,6 +37,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 @@ -405,18 +450,13 @@ 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:
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