From d5a5c75077288878a05fe7d4707e142ab1ccdc3f Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 27 Jun 2026 10:46:34 -0400 Subject: [PATCH 1/3] Fix CVE-2026-59890: Normalize Unicode form when matching MANIFEST.in patterns FileList matched MANIFEST.in patterns against on-disk names byte-for-byte with no Unicode normalization. On macOS APFS/HFS+, a file stored NFD and a pattern authored NFC denote the same file but differ byte-for-byte, so an exclude/global-exclude/recursive-exclude/prune rule could silently fail to drop a non-ASCII-named file, publishing it in the sdist despite the rule. Normalize both the pattern and the candidate path to NFC before matching, via a new unicode_utils.normalize() helper and a _NormalizedMatcher wrapper around the compiled pattern in translate_pattern. Fixes GHSA-h35f-9h28-mq5c. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit dd9f436a36486b4cb8a4c70a2321548b0be09b8f) Co-Authored-By: Claude Opus 5.5 68.0.0.x: backported from the ActiveState 82.0.1.x fix (bb32ccbf8); only the test module's imports conflicted. The fix uses no Python 3.8+ features (re.Pattern exists from 3.7), so 68.0.0.x keeps Python 3.7. The towncrier fragment is dropped; CHANGES.rst gets the entry in the release commit. (cherry picked from commit bb32ccbf8b27db772dc8ba18729ea26350f7892c) --- setuptools/command/egg_info.py | 28 ++++++++++++++++++- setuptools/tests/test_manifest.py | 45 +++++++++++++++++++++++++++++++ setuptools/unicode_utils.py | 14 ++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/setuptools/command/egg_info.py b/setuptools/command/egg_info.py index 66228f9bee..6a5b271ff6 100644 --- a/setuptools/command/egg_info.py +++ b/setuptools/command/egg_info.py @@ -35,6 +35,27 @@ PY_MAJOR = '{}.{}'.format(*sys.version_info) +class _NormalizedMatcher: + """ + 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 GHSA-h35f-9h28-mq5c. + """ + + def __init__(self, pattern: re.Pattern) -> None: + 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): # noqa: C901 # is too complex (14) # FIXME """ Translate a file path glob like '*.txt' in to a regular expression. @@ -44,6 +65,11 @@ def translate_pattern(glob): # noqa: C901 # is too complex (14) # FIXME """ pat = '' + # Normalize the pattern so it matches paths regardless of the Unicode + # normalization form used on disk (GHSA-h35f-9h28-mq5c). 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) @@ -115,7 +141,7 @@ def translate_pattern(glob): # noqa: C901 # is too complex (14) # FIXME 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: diff --git a/setuptools/tests/test_manifest.py b/setuptools/tests/test_manifest.py index 3a973b01c4..048e258991 100644 --- a/setuptools/tests/test_manifest.py +++ b/setuptools/tests/test_manifest.py @@ -10,6 +10,7 @@ import logging from distutils import log from distutils.errors import DistutilsTemplateError +import unicodedata from setuptools.command.egg_info import FileList, egg_info, translate_pattern from setuptools.dist import Distribution @@ -159,6 +160,21 @@ def test_translated_pattern_mismatch(pattern_mismatch): assert not translate_pattern(pattern).match(target) +def test_translate_pattern_unicode_normalization(): + """ + 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. + + Regression test for GHSA-h35f-9h28-mq5c. + """ + nfc = unicodedata.normalize('NFC', 'café.txt') # 'café.txt' composed + nfd = unicodedata.normalize('NFD', 'café.txt') # 'café.txt' decomposed + assert nfc != nfd # the two byte forms genuinely differ + assert translate_pattern(nfc).match(nfd) + assert translate_pattern(nfd).match(nfc) + + class TempDirTestCase: def setup_method(self, method): self.temp_dir = tempfile.mkdtemp() @@ -317,6 +333,35 @@ 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 + case/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 GHSA-h35f-9h28-mq5c. + """ + nfc_name = unicodedata.normalize('NFC', 'café.txt') + nfd_name = unicodedata.normalize('NFD', 'café.txt') + assert nfc_name != nfd_name + # write the file under its decomposed (NFD) name ... + touch(os.path.join(self.temp_dir, 'app', nfd_name)) + # ... and exclude it with the composed (NFC) form. + self.make_manifest( + f""" + global-include *.txt + global-exclude {nfc_name} + """ + ) + leaked = { + f + for f in self.get_files() + if unicodedata.normalize('NFC', os.path.basename(f)) == nfc_name + } + assert not leaked, f"excluded file leaked into manifest: {leaked}" + class TestFileListTest(TempDirTestCase): """ diff --git a/setuptools/unicode_utils.py b/setuptools/unicode_utils.py index e84e65e3e1..b50e040891 100644 --- a/setuptools/unicode_utils.py +++ b/setuptools/unicode_utils.py @@ -15,6 +15,20 @@ def decompose(path): return path +def normalize(text): + """ + Return *text* 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 (GHSA-h35f-9h28-mq5c). + """ + return unicodedata.normalize('NFC', text) if isinstance(text, str) else text + + def filesys_decode(path): """ Ensure that the given path is decoded, From ea21ac2a42b3fd1ccf3b872d4cbba5ec7657ff9f Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Thu, 29 Oct 2026 11:56:12 -0500 Subject: [PATCH 2/3] Validate namespace package names during legacy wheel installation Entries in a wheel's namespace_packages.txt were split on '.' and passed straight to os.path.join, which interprets its arguments as paths. An entry that is absolute, drive-qualified, or UNC therefore discarded the destination egg directory entirely and placed a fixed-content __init__.py at a path chosen by the wheel. The report framed this as Windows-only, but only the drive-letter form is; an absolute POSIX path escapes just as readily. Namespace entries are now validated as dotted module names on every platform, with a containment check as defense in depth. This sink is reachable only through the deprecated setup_requires mechanism when setup() is invoked outside a PEP 517 frontend. The setuptools build backend disables setup_requires installation in every hook, so it does not reach it. Ref GHSA-xmj3-9gf7-h52w. (cherry picked from commit 6d07b8d17890dda58d944ac51e58f54af92d52af) 68.0.0.x: hand-applied. 68.0.0 reads namespace_packages.txt in Wheel._fix_namespace_packages() with the same os.path.join(destination_eggdir, *mod.split('.')) sink; that line now calls _resolve_namespace_dir(). The helper and tests are taken unchanged (str.isidentifier() and f-strings are fine on Python 3.7). The towncrier fragment is dropped; CHANGES.rst gets the entry in the release commit. Co-Authored-By: Claude Opus 5 Co-Authored-By: Claude Opus 5.5 --- setuptools/tests/test_wheel.py | 56 +++++++++++++++++++++++++++++++++- setuptools/wheel.py | 40 +++++++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/setuptools/tests/test_wheel.py b/setuptools/tests/test_wheel.py index 559e16d758..8e6714db20 100644 --- a/setuptools/tests/test_wheel.py +++ b/setuptools/tests/test_wheel.py @@ -20,7 +20,7 @@ from pkg_resources import Distribution, PathMetadata, PY_MAJOR from setuptools.extern.packaging.utils import canonicalize_name from setuptools.extern.packaging.tags import parse_tag -from setuptools.wheel import Wheel +from setuptools.wheel import Wheel, _resolve_namespace_dir from .contexts import tempdir from .textwrap import DALS @@ -701,3 +701,57 @@ def build_wheel(extra_file_defs=None, **kwargs): if sys.platform != 'win32': # Editable file mode has no effect on Windows assert oct(stat.S_IMODE(script_sh.stat().st_mode)) == "0o777" + + +@pytest.mark.parametrize( + "mod", + [ + pytest.param('/tmp/outside', id='posix_absolute'), + pytest.param(r'C:\Windows', id='drive_qualified'), + pytest.param(r'\\server\share', id='unc'), + pytest.param('foo/../../evil', id='separators'), + pytest.param('..', id='parent'), + pytest.param('', id='empty'), + ], +) +def test_namespace_package_escape(tmp_path, mod): + """ + A namespace entry that is not a dotted module name must be rejected + rather than resolved as a path (GHSA-xmj3-9gf7-h52w). + """ + with pytest.raises(ValueError, match='invalid namespace package name'): + _resolve_namespace_dir(str(tmp_path), mod) + + +def test_namespace_package_resolves_dotted_name(tmp_path): + expected = tmp_path / 'ns' / 'pkg' + assert _resolve_namespace_dir(str(tmp_path), 'ns.pkg') == str(expected) + + +def test_install_as_egg_rejects_namespace_escape(tmp_path): + """ + An attacker-controlled ``namespace_packages.txt`` must not create files + outside the destination egg directory. + """ + outside = tmp_path / 'outside' + outside.mkdir() + dest = tmp_path / 'destination' + + whl = tmp_path / 'demo_pkg-1.0-py3-none-any.whl' + dist_info = 'demo_pkg-1.0.dist-info' + with zipfile.ZipFile(whl, 'w') as zf: + zf.writestr( + f'{dist_info}/WHEEL', + 'Wheel-Version: 1.0\nGenerator: test\n' + 'Root-Is-Purelib: true\nTag: py3-none-any\n', + ) + zf.writestr( + f'{dist_info}/METADATA', + 'Metadata-Version: 2.1\nName: demo-pkg\nVersion: 1.0\n', + ) + zf.writestr(f'{dist_info}/namespace_packages.txt', f'{outside}\n') + + with pytest.raises(ValueError, match='invalid namespace package name'): + Wheel(str(whl)).install_as_egg(str(dest)) + + assert not (outside / '__init__.py').exists() diff --git a/setuptools/wheel.py b/setuptools/wheel.py index 850e43cd01..3993e26cf3 100644 --- a/setuptools/wheel.py +++ b/setuptools/wheel.py @@ -72,6 +72,44 @@ def disable_info_traces(): log.set_threshold(saved) +def _resolve_namespace_dir(destination_eggdir, mod): + r""" + Return the directory for namespace package `mod` under `destination_eggdir`. + + `mod` is a dotted module name, so every component must be an identifier. + ``os.path.join`` interprets its arguments as paths, so a component that is + absolute, drive-qualified, or UNC discards `destination_eggdir` entirely, + and one containing a separator escapes it (GHSA-xmj3-9gf7-h52w). Only + Windows treats a drive letter that way, but an absolute POSIX path escapes + just as readily, so the name is validated on every platform. + + >>> _resolve_namespace_dir('dest', 'ns.pkg') == os.path.join('dest', 'ns', 'pkg') + True + >>> _resolve_namespace_dir('dest', '/tmp/outside') + Traceback (most recent call last): + ... + ValueError: invalid namespace package name: '/tmp/outside' + >>> _resolve_namespace_dir('dest', r'C:\Windows') + Traceback (most recent call last): + ... + ValueError: invalid namespace package name: 'C:\\Windows' + """ + parts = mod.split('.') + + if not all(part.isidentifier() for part in parts): + raise ValueError(f"invalid namespace package name: {mod!r}") + + dest = os.path.join(destination_eggdir, *parts) + + # Belt and braces: confirm the result really does resolve within the egg + # directory, catching an escape through a symlink already present in it. + root = os.path.realpath(destination_eggdir) + if not os.path.realpath(dest).startswith(os.path.join(root, '')): + raise ValueError(f"invalid namespace package name: {mod!r}") + + return dest + + class Wheel: def __init__(self, filename): @@ -222,7 +260,7 @@ def _fix_namespace_packages(egg_info, destination_eggdir): with open(namespace_packages) as fp: namespace_packages = fp.read().split() for mod in namespace_packages: - mod_dir = os.path.join(destination_eggdir, *mod.split('.')) + mod_dir = _resolve_namespace_dir(destination_eggdir, mod) mod_init = os.path.join(mod_dir, '__init__.py') if not os.path.exists(mod_dir): os.mkdir(mod_dir) From 51d7b35ccc6333a28ce49e31fa01554409416de1 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Thu, 29 Oct 2026 11:57:34 -0500 Subject: [PATCH 3/3] Update to version 68.0.0.3 Security release of the setuptools 68.0.0 line (the last setuptools that supports Python 3.7): CVE-2026-59890 (GHSA-h35f-9h28-mq5c) and GHSA-xmj3-9gf7-h52w. Co-Authored-By: Claude Opus 5.5 --- .bumpversion.cfg | 2 +- CHANGES.rst | 23 +++++++++++++++++++++++ setup.cfg | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 349835bae7..70b23254f5 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 68.0.0.2 +current_version = 68.0.0.3 commit = True tag = True diff --git a/CHANGES.rst b/CHANGES.rst index fc44ea1906..f73ec40f60 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,26 @@ +v68.0.0.3 +--------- + +Changes +^^^^^^^ +* GHSA-h35f-9h28-mq5c: Security Fix for CVE-2026-59890 + ``MANIFEST.in`` patterns were matched against file names without Unicode + normalization. A file stored with a decomposed (NFD) name, as on macOS + APFS/HFS+, was not dropped by an ``exclude``/``global-exclude``/ + ``recursive-exclude``/``prune`` rule written in composed (NFC) form, so it + could be published in the sdist despite the rule. Patterns and paths are + now both normalized to NFC before matching. Fixed upstream in 83.0.0. +* GHSA-xmj3-9gf7-h52w: Path escape via ``namespace_packages.txt`` during + legacy wheel installation + Entries of a wheel's ``namespace_packages.txt`` were joined onto the + destination egg directory as paths, so an absolute, drive-qualified or UNC + entry placed an ``__init__.py`` outside it. Entries must now be dotted + module names that resolve inside the egg directory. Reachable only through + the deprecated ``setup_requires`` mechanism. Fixed upstream in 84.1.0. + +Misc +^^^^ + v68.0.0.2 --------- diff --git a/setup.cfg b/setup.cfg index a2e0691d3e..067b105886 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = setuptools -version = 68.0.0.2 +version = 68.0.0.3 author = Python Packaging Authority author_email = distutils-sig@python.org description = Easily download, build, install, upgrade, and uninstall Python packages