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 = 68.0.0.2
current_version = 68.0.0.3
commit = True
tag = True

Expand Down
23 changes: 23 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -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
---------

Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
@@ -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
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 @@ -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.
Expand All @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
45 changes: 45 additions & 0 deletions setuptools/tests/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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):
"""
Expand Down
56 changes: 55 additions & 1 deletion setuptools/tests/test_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
14 changes: 14 additions & 0 deletions setuptools/unicode_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
40 changes: 39 additions & 1 deletion setuptools/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
Loading