Skip to content

Validate namespace package names during legacy wheel installation - #5329

Open
jaraco wants to merge 1 commit into
mainfrom
fix/wheel-namespace-path-escape
Open

jaraco wants to merge 1 commit into
mainfrom
fix/wheel-namespace-path-escape

Conversation

@jaraco

@jaraco jaraco commented Sep 10, 2026

Copy link
Copy Markdown
Member

Hardening for GHSA-xmj3-9gf7-h52w, reported privately by @Hades2508.

The issue

Wheel._fix_namespace_packages split each entry of a wheel's
namespace_packages.txt on . and passed the components straight to
os.path.join, which interprets its arguments as paths:

mod_dir = os.path.join(destination_eggdir, *mod.split('.'))

An entry with no dots and an absolute form is a single component, so
os.path.join discards destination_eggdir and resolves to the
attacker-chosen path, where a fixed-content __init__.py is then created.

The report framed this as Windows-only (drive-qualified paths), but only
that form is Windows-specific — an absolute POSIX path escapes just as
readily, confirmed end-to-end against the real install_as_egg on macOS:

'/tmp/pwned'.split('.')  -> ['/tmp/pwned']  -> os.path.join(dest, '/tmp/pwned') == '/tmp/pwned'

Only bare .. was already neutral, since '..'.split('.') yields empty
components.

Impact

Low, and deliberately being handled in public rather than embargoed.

setuptools.installer is the only remaining caller of install_as_egg
(easy_install is now a stub), so the sink is reachable only via the
deprecated setup_requires mechanism with setup() invoked outside a
PEP 517 frontend. The build backend does not reach it — every hook either
runs under no_install_setup_requires() or under Distribution.patch(),
whose fetch_build_eggs reports requirements to the frontend instead of
installing them. Verified by instrumenting the sink:

build_meta.get_requires_for_build_wheel       sink_hits=0
build_meta.prepare_metadata_for_build_wheel   sink_hits=0
build_meta.build_wheel                        sink_hits=0
build_meta.build_sdist                        sink_hits=0
build_meta.__legacy__.build_wheel             sink_hits=0
setuptools.setup() directly (setup.py)        sink_hits=1

Moreover, an attacker who can satisfy the precondition — controlling the
wheel resolved for a setup_requires entry — already gets arbitrary code
execution, since _fetch_build_eggs prepends the resulting egg to
sys.path for the build to import. A fixed-content, non-overwriting
__init__.py whose parent directory must already exist is strictly weaker
than what that attacker already has.

The one residue not fully subsumed: writing __init__.py into an existing
PEP 420 namespace portion could alter import resolution. That motivates
fixing it as defense in depth.

The change

Namespace entries are dotted module names, so every component must be an
identifier. That single check rejects separators, absolute paths, drive
letters, UNC prefixes, .., and empty components on every platform, and a
realpath containment check follows as belt and braces — mirroring
_resolve_dest from #5325.

Invalid entries now raise ValueError, consistent with the other
malformed-wheel errors in this module.

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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mergify

mergify Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@Hades2508

Copy link
Copy Markdown

Thanks for the fix. I reviewed the patch against the original issue and some additional edge cases, and I don't currently see a bypass.
I tested a broader matrix locally:

  • 15/15 invalid namespace forms were rejected, including POSIX and Windows absolute paths, drive-qualified forms, Windows separators, .., empty/degenerate components, and related variants.
  • 7/7 legitimate namespace names continued to pass, including a non-ASCII identifier.
  • I also exercised the realpath containment branch on Linux using a symlink created inside the egg directory that points outside it; the escape was rejected.
    One thing that may also help with the current coverage failure: from the added lines, the containment raise ValueError(...) appears to be the only new branch not exercised by the existing tests. A symlink-containment regression test reaches that branch and passes with the current patch.
    I also noticed two other regression cases that may be worth pinning:
  1. In test_install_as_egg_rejects_namespace_escape, after asserting ValueError, also assert that no init.py was created in the outside directory. That verifies the security outcome, not only the exception.
  2. Add a PEP 420 regression case where an existing namespace portion outside destination_eggdir remains untouched and does not gain an init.py.
    I ran those cases against the current patch and they pass.
    Also, your correction to my original Windows-only framing is right: the absolute-path form is not Windows-specific. My initial report was too narrow there.
    Overall, the patch looks good from the cases I tested.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants