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
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 = 44.1.1+security.2
current_version = 44.1.1.post3
commit = True
tag = True

Expand Down
16 changes: 16 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
v44.1.1.post3
-------------

* Version ordering: this release uses the ``.postN`` scheme. Under PEP 440
it sorts after ``44.1.1+security.2`` but *below* a build labelled
``44.1.1.1`` (the 2023 CVE-2022-40897 release, listed that way in the
ActiveState Platform inventory). Do not serve a ``44.1.1.1`` alongside this
release: ``setuptools<45`` would resolve to it and miss every fix since.
* Fixed ``MANIFEST.in`` matching being sensitive to Unicode normalization
form. A pattern authored NFC (as editors usually save it) did not match a
file whose name is stored NFD (as on macOS APFS/HFS+), so an ``exclude``,
``global-exclude``, ``recursive-exclude`` or ``prune`` rule could silently
fail to drop a non-ASCII-named file from the sdist. Patterns and candidate
paths are now both normalized to NFC before matching (CVE-2026-59890,
GHSA-h35f-9h28-mq5c).

v44.1.1+security.2
------------------

Expand Down
6 changes: 1 addition & 5 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
[egg_info]
tag_build = .post
tag_date = 1

[aliases]
clean_egg_info = egg_info -Db ''
release = clean_egg_info sdist bdist_wheel
Expand All @@ -19,7 +15,7 @@ universal = 1

[metadata]
name = setuptools
version = 44.1.1+security.2
version = 44.1.1.post3
description = Easily download, build, install, upgrade, and uninstall Python packages
author = Python Packaging Authority
author_email = distutils-sig@python.org
Expand Down
48 changes: 47 additions & 1 deletion setuptools/command/egg_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,47 @@
from setuptools.extern import packaging
from setuptools import SetuptoolsDeprecationWarning


class _NormalizedMatcher(object):
"""
Wrap a compiled pattern so that matching is insensitive to Unicode
normalization form.

File names walked from disk (NFD on macOS APFS/HFS+) and patterns from
``MANIFEST.in`` (typically NFC) can denote the same file while differing
byte-for-byte. Normalizing both sides before matching keeps an exclusion
(or inclusion) from silently failing. See CVE-2026-59890.
"""

def __init__(self, pattern):
self._pattern = pattern

def _prepare(self, path):
path = unicode_utils.normalize(path)
# On Python 2 a MANIFEST.in pattern and a FileList entry can be of
# different string types (e.g. a file finder returning text paths for
# non-ASCII names), and a byte pattern never matches a non-ASCII text
# path or vice versa. Bring the path to the pattern's type first.
if isinstance(self._pattern.pattern, six.text_type):
if isinstance(path, bytes):
try:
path = path.decode('utf-8')
except UnicodeDecodeError:
pass
elif isinstance(path, six.text_type):
try:
path = path.encode('utf-8')
except UnicodeEncodeError:
pass
return path

def match(self, path):
return self._pattern.match(self._prepare(path))

def search(self, path):
return self._pattern.search(self._prepare(path))


def translate_pattern(glob):
"""
Translate a file path glob like '*.txt' in to a regular expression.
Expand All @@ -42,6 +83,11 @@ def translate_pattern(glob):
"""
pat = ''

# Normalize the pattern so it matches paths regardless of the Unicode
# normalization form used on disk (CVE-2026-59890). Candidate paths
# are normalized to the same form by ``_NormalizedMatcher``.
glob = unicode_utils.normalize(glob)

# This will split on '/' within [character classes]. This is deliberate.
chunks = glob.split(os.path.sep)

Expand Down Expand Up @@ -113,7 +159,7 @@ def translate_pattern(glob):
pat += sep

pat += r'\Z'
return re.compile(pat, flags=re.MULTILINE|re.DOTALL)
return _NormalizedMatcher(re.compile(pat, flags=re.MULTILINE|re.DOTALL))


class InfoCommon:
Expand Down
70 changes: 70 additions & 0 deletions setuptools/tests/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import sys
import tempfile
import itertools
import unicodedata
from distutils import log
from distutils.errors import DistutilsTemplateError

Expand Down Expand Up @@ -159,6 +160,50 @@ def test_translated_pattern_mismatch(pattern_mismatch):
assert not translate_pattern(pattern).match(target)


NFC_NAME = unicodedata.normalize('NFC', u'caf\u00e9.txt')
NFD_NAME = unicodedata.normalize('NFD', u'caf\u00e9.txt')


@pytest.mark.parametrize('encode', [False, True], ids=['text', 'bytes'])
def test_translate_pattern_unicode_normalization(encode):
"""
Matching is insensitive to Unicode normalization form: a pattern authored
in one form matches a path stored on disk in another (and vice versa), so
that an exclusion cannot be bypassed by an NFC/NFD mismatch. The bytes
case covers Python 2, where MANIFEST.in lines and walked file names are
native byte strings.

Regression test for CVE-2026-59890 (GHSA-h35f-9h28-mq5c).
"""
if encode and six.PY3:
pytest.skip('byte-string patterns are only used on Python 2')
nfc, nfd = NFC_NAME, NFD_NAME
if encode:
nfc, nfd = nfc.encode('utf-8'), nfd.encode('utf-8')
assert nfc != nfd # the two forms genuinely differ
assert translate_pattern(nfc).match(nfd)
assert translate_pattern(nfd).match(nfc)
assert translate_pattern(nfc).search(nfd)



@pytest.mark.skipif(six.PY3, reason='mixed str/bytes paths only arise on Python 2')
@pytest.mark.parametrize('name', [NFC_NAME, NFD_NAME], ids=['nfc', 'nfd'])
def test_translate_pattern_mixed_string_types(name):
"""
On Python 2 a byte-string pattern must match a text path, and a text
pattern a byte-string path, so that an exclusion is not bypassed when
MANIFEST.in lines and FileList entries differ in type.
"""
encoded = name.encode('utf-8')
assert translate_pattern(NFC_NAME.encode('utf-8')).match(name)
assert translate_pattern(NFC_NAME).match(encoded)

file_list = FileList()
file_list.files = [name, u'other.txt']
file_list.global_exclude(NFC_NAME.encode('utf-8'))
assert file_list.files == [u'other.txt']

class TempDirTestCase:
def setup_method(self, method):
self.temp_dir = tempfile.mkdtemp()
Expand Down Expand Up @@ -317,6 +362,31 @@ def test_graft_prune(self):
ml('app/a.txt'), ml('app/b.txt'), ml('app/c.rst')])
assert files == self.get_files()

def test_global_exclude_unicode_normalization(self):
"""
A ``global-exclude`` authored NFC must drop a file whose on-disk name
is NFD: on macOS APFS/HFS+ the two are the same file, and even on
normalization-exact filesystems the decomposed name can be committed
and reach the build. Otherwise the file is published in the sdist
despite the exclusion.

Regression test for CVE-2026-59890 (GHSA-h35f-9h28-mq5c).
"""
nfd_path = os.path.join(self.temp_dir, 'app', NFD_NAME)
if six.PY2:
nfd_path = nfd_path.encode('utf-8')
# write the file under its decomposed (NFD) name ...
touch(nfd_path)
# ... and exclude it with the composed (NFC) form.
manifest = u'global-include *.txt\nglobal-exclude %s\n' % NFC_NAME
with open(os.path.join(self.temp_dir, 'MANIFEST.in'), 'wb') as f:
f.write(manifest.encode('utf-8'))
leaked = set(
f for f in self.get_files()
if os.path.basename(f).startswith('caf')
)
assert not leaked, 'excluded file leaked into manifest: %r' % leaked


class TestFileListTest(TempDirTestCase):
"""
Expand Down
25 changes: 25 additions & 0 deletions setuptools/unicode_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,31 @@ def decompose(path):
return path


def normalize(path):
"""
Return *path* in a canonical Unicode form (NFC) so that names which are
visually identical but encoded differently compare equal.

macOS APFS/HFS+ store file names in decomposed form (NFD), while patterns
in ``MANIFEST.in`` are typically authored composed (NFC). The two denote
the same file but differ byte-for-byte, so matching them directly lets an
exclusion silently fail. Normalizing both the walked path and the pattern
to a single form before matching avoids that (CVE-2026-59890).

Byte strings (the native ``str`` on Python 2) are normalized as UTF-8 and
returned as bytes; anything that is not valid UTF-8 is returned unchanged.
"""
if isinstance(path, six.text_type):
return unicodedata.normalize('NFC', path)
try:
path = path.decode('utf-8')
path = unicodedata.normalize('NFC', path)
path = path.encode('utf-8')
except UnicodeError:
pass # Not UTF-8
return path


def filesys_decode(path):
"""
Ensure that the given path is decoded,
Expand Down