Skip to content

v68.0.0.2 - CVE-2025-47273 Cherry Pick fix 250a6d1 #5062

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
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
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 68.0.0
current_version = 68.0.0.2
commit = True
tag = True

Expand Down
30 changes: 30 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,33 @@
v68.0.0.2
---------

Changes
^^^^^^^
* #4946: Security Fix for CVE-2025-47273
A path traversal vulnerability in `PackageIndex` is present in setuptools prior to version
78.1.1. An attacker would be allowed to write files to arbitrary locations on the filesystem
with the permissions of the process running the Python code, which could escalate to remote code
execution depending on the context. Version 78.1.1 fixes the issue.

Misc
^^^^

v68.0.0.1
---------

Changes
^^^^^^^
* #4332: Security Fix for CVE-2024-6345
A vulnerability in the **package_index** module of pypa/setuptools versions up to 69.1.1
allows for **remote code execution** via its download functions. These functions, which
are used to download packages from URLs provided by users or retrieved from package index
servers, are susceptible to **code injection**. If these functions are exposed to
user-controlled inputs, such as package URLs, they can execute **arbitrary commands**
on the system. The issue is fixed in version 70.0.

Misc
^^^^

v68.0.0
-------

Expand Down
20 changes: 16 additions & 4 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[metadata]
name = setuptools
version = 68.0.0
version = 68.0.0.2
author = Python Packaging Authority
author_email = [email protected]
description = Easily download, build, install, upgrade, and uninstall Python packages
Expand Down Expand Up @@ -70,7 +70,19 @@ testing =
ini2toml[lite]>=0.9
tomli-w>=1.0.0
pytest-timeout
pytest-perf
pytest-perf; \
# workaround for jaraco/inflect#195, pydantic/pydantic-core#773 (see #3986)
sys_platform != "cygwin"
# for tools/finalize.py
jaraco.develop >= 7.21; python_version >= "3.9" and sys_platform != "cygwin"
; pytest-home >= 0.5
; mypy==1.9 # pin mypy version so a new version doesn't suddenly cause the CI to fail
mypy
# No Python 3.11 dependencies require tomli, but needed for type-checking since we import it directly
tomli
# No Python 3.12 dependencies require importlib_metadata, but needed for type-checking since we import it directly
importlib_metadata
pytest-subprocess

testing-integration =
pytest
Expand Down Expand Up @@ -164,8 +176,8 @@ egg_info.writers =
dependency_links.txt = setuptools.command.egg_info:overwrite_arg

[egg_info]
tag_build = .post
tag_date = 1
tag_build =
tag_date = 0

[sdist]
formats = zip
1 change: 1 addition & 0 deletions setuptools/_distutils/command/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def finalize_options(self): # noqa: C901
self.build_scripts = os.path.join(
self.build_base, 'scripts-%d.%d' % sys.version_info[:2]
)
os.makedirs(self.build_scripts, exist_ok=True)

if self.executable is None and sys.executable:
self.executable = os.path.normpath(sys.executable)
Expand Down
223 changes: 122 additions & 101 deletions setuptools/package_index.py
Original file line number Diff line number Diff line change
@@ -1,45 +1,33 @@
"""PyPI and direct package downloading."""

import sys
import os
import re
import io
import shutil
import socket
import base64
import hashlib
import itertools
import configparser
import hashlib
import html
import http.client
import io
import itertools
import os
import re
import shutil
import socket
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
import urllib.error
from functools import wraps

import setuptools
from pkg_resources import (
CHECKOUT_DIST,
Distribution,
BINARY_DIST,
normalize_path,
SOURCE_DIST,
Environment,
find_distributions,
safe_name,
safe_version,
to_filename,
Requirement,
DEVELOP_DIST,
EGG_DIST,
parse_version,
)
from distutils import log
from distutils.errors import DistutilsError
from fnmatch import translate
from setuptools.wheel import Wheel
from setuptools.extern.more_itertools import unique_everseen
from functools import wraps

import setuptools
from pkg_resources import (BINARY_DIST, CHECKOUT_DIST, DEVELOP_DIST, EGG_DIST,
SOURCE_DIST, Distribution, Environment, Requirement,
find_distributions, normalize_path, parse_version, safe_name,
safe_version, to_filename)
from setuptools.extern.more_itertools import unique_everseen
from setuptools.wheel import Wheel

EGG_FRAGMENT = re.compile(r'^egg=([-A-Za-z0-9_.+!]+)$')
HREF = re.compile(r"""href\s*=\s*['"]?([^'"> ]+)""", re.I)
Expand Down Expand Up @@ -195,7 +183,7 @@ def interpret_distro_name(
'-'.join(parts[p:]),
py_version=py_version,
precedence=precedence,
platform=platform
platform=platform,
)


Expand Down Expand Up @@ -305,7 +293,7 @@ def __init__(
ca_bundle=None,
verify_ssl=True,
*args,
**kw
**kw,
):
super().__init__(*args, **kw)
self.index_url = index_url + "/"[: not index_url.endswith('/')]
Expand Down Expand Up @@ -586,7 +574,7 @@ def download(self, spec, tmpdir):
scheme = URL_SCHEME(spec)
if scheme:
# It's a url, download it to tmpdir
found = self._download_url(scheme.group(1), spec, tmpdir)
found = self._download_url(spec, tmpdir)
base, fragment = egg_info_for_url(spec)
if base.endswith('.py'):
found = self.gen_setup(found, fragment, tmpdir)
Expand Down Expand Up @@ -813,10 +801,25 @@ def open_url(self, url, warning=None): # noqa: C901 # is too complex (12)
else:
raise DistutilsError("Download error for %s: %s" % (url, v)) from v

def _download_url(self, scheme, url, tmpdir):
# Determine download filename
#
name, fragment = egg_info_for_url(url)
@staticmethod
def _resolve_download_filename(url, tmpdir):
"""
>>> import pathlib
>>> du = PackageIndex._resolve_download_filename
>>> root = getfixture('tmp_path')
>>> url = 'https://files.pythonhosted.org/packages/a9/5a/0db.../setuptools-78.1.0.tar.gz'
>>> str(pathlib.Path(du(url, root)).relative_to(root))
'setuptools-78.1.0.tar.gz'

Ensures the target is always in tmpdir.

>>> url = 'https://anyhost/%2fhome%2fuser%2f.ssh%2fauthorized_keys'
>>> du(url, root)
Traceback (most recent call last):
...
ValueError: Invalid filename...
"""
name, _fragment = egg_info_for_url(url)
if name:
while '..' in name:
name = name.replace('..', '.').replace('\\', '_')
Expand All @@ -828,19 +831,64 @@ def _download_url(self, scheme, url, tmpdir):

filename = os.path.join(tmpdir, name)

# Download the file
#
if scheme == 'svn' or scheme.startswith('svn+'):
return self._download_svn(url, filename)
elif scheme == 'git' or scheme.startswith('git+'):
return self._download_git(url, filename)
elif scheme.startswith('hg+'):
return self._download_hg(url, filename)
elif scheme == 'file':
return urllib.request.url2pathname(urllib.parse.urlparse(url)[2])
else:
self.url_ok(url, True) # raises error if not allowed
return self._attempt_download(url, filename)
# ensure path resolves within the tmpdir
if not filename.startswith(str(tmpdir)):
raise ValueError(f"Invalid filename {filename}")

return filename


@staticmethod
def _resolve_vcs(url):
"""
>>> rvcs = PackageIndex._resolve_vcs
>>> rvcs('git+http://foo/bar')
'git'
>>> rvcs('hg+https://foo/bar')
'hg'
>>> rvcs('git:myhost')
'git'
>>> rvcs('hg:myhost')
>>> rvcs('http://foo/bar')
"""
scheme = urllib.parse.urlsplit(url).scheme
pre, sep, post = scheme.partition('+')
# svn and git have their own protocol; hg does not
allowed = set(['svn', 'git'] + ['hg'] * bool(sep))
return next(iter({pre} & allowed), None)

def _download_vcs(self, url, spec_filename):
vcs = self._resolve_vcs(url)
if not vcs:
return
if vcs == 'svn':
raise DistutilsError(
f"Invalid config, SVN download is not supported: {url}"
)

filename, _, _ = spec_filename.partition('#')
url, rev = self._vcs_split_rev_from_url(url)

self.info(f"Doing {vcs} clone from {url} to {filename}")
subprocess.check_call([vcs, 'clone', '--quiet', url, filename])

co_commands = dict(
git=[vcs, '-C', filename, 'checkout', '--quiet', rev],
hg=[vcs, '--cwd', filename, 'up', '-C', '-r', rev, '-q'],
)
if rev is not None:
self.info(f"Checking out {rev}")
subprocess.check_call(co_commands[vcs])

return filename

def _download_other(self, url, filename):
scheme = urllib.parse.urlsplit(url).scheme
if scheme == 'file': # pragma: no cover
return urllib.request.url2pathname(urllib.parse.urlparse(url).path)
# raise error if not allowed
self.url_ok(url, True)
return self._attempt_download(url, filename)

def scan_url(self, url):
self.process_url(url, True)
Expand All @@ -856,64 +904,37 @@ def _invalid_download_html(self, url, headers, filename):
os.unlink(filename)
raise DistutilsError(f"Unexpected HTML page found at {url}")

def _download_svn(self, url, _filename):
raise DistutilsError(f"Invalid config, SVN download is not supported: {url}")

@staticmethod
def _vcs_split_rev_from_url(url, pop_prefix=False):
scheme, netloc, path, query, frag = urllib.parse.urlsplit(url)
def _vcs_split_rev_from_url(url):
"""
Given a possible VCS URL, return a clean URL and resolved revision if any.

>>> vsrfu = PackageIndex._vcs_split_rev_from_url
>>> vsrfu('git+https://github.com/pypa/[email protected]#egg-info=setuptools')
('https://github.com/pypa/setuptools', 'v69.0.0')
>>> vsrfu('git+https://github.com/pypa/setuptools#egg-info=setuptools')
('https://github.com/pypa/setuptools', None)
>>> vsrfu('http://foo/bar')
('http://foo/bar', None)
"""
parts = urllib.parse.urlsplit(url)

scheme = scheme.split('+', 1)[-1]
clean_scheme = parts.scheme.split('+', 1)[-1]

# Some fragment identification fails
path = path.split('#', 1)[0]

rev = None
if '@' in path:
path, rev = path.rsplit('@', 1)

# Also, discard fragment
url = urllib.parse.urlunsplit((scheme, netloc, path, query, ''))
no_fragment_path, _, _ = parts.path.partition('#')

return url, rev
pre, sep, post = no_fragment_path.rpartition('@')
clean_path, rev = (pre, post) if sep else (post, None)

def _download_git(self, url, filename):
filename = filename.split('#', 1)[0]
url, rev = self._vcs_split_rev_from_url(url, pop_prefix=True)
resolved = parts._replace(
scheme=clean_scheme,
path=clean_path,
# discard the fragment
fragment='',
).geturl()

self.info("Doing git clone from %s to %s", url, filename)
os.system("git clone --quiet %s %s" % (url, filename))

if rev is not None:
self.info("Checking out %s", rev)
os.system(
"git -C %s checkout --quiet %s"
% (
filename,
rev,
)
)

return filename

def _download_hg(self, url, filename):
filename = filename.split('#', 1)[0]
url, rev = self._vcs_split_rev_from_url(url, pop_prefix=True)

self.info("Doing hg clone from %s to %s", url, filename)
os.system("hg clone --quiet %s %s" % (url, filename))

if rev is not None:
self.info("Updating to %s", rev)
os.system(
"hg --cwd %s up -C -r %s -q"
% (
filename,
rev,
)
)

return filename
return resolved, rev

def debug(self, msg, *args):
log.debug(msg, *args)
Expand Down
Loading