Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
11 changes: 11 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
v44.1.1.post3
-------------

* 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
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,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
28 changes: 27 additions & 1 deletion setuptools/command/egg_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,27 @@
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 match(self, path):
return self._pattern.match(unicode_utils.normalize(path))

def search(self, path):
return self._pattern.search(unicode_utils.normalize(path))


def translate_pattern(glob):
"""
Translate a file path glob like '*.txt' in to a regular expression.
Expand All @@ -42,6 +63,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 +139,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
52 changes: 52 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,32 @@ def test_translated_pattern_mismatch(pattern_mismatch):
assert not translate_pattern(pattern).match(target)


NFC_NAME = unicodedata.normalize('NFC', u'café.txt')
NFD_NAME = unicodedata.normalize('NFD', u'café.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)


class TempDirTestCase:
def setup_method(self, method):
self.temp_dir = tempfile.mkdtemp()
Expand Down Expand Up @@ -317,6 +344,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