Skip to content

Reject archive members that escape the extraction directory - #5325

Merged
jaraco merged 4 commits into
mainfrom
fix/archive-util-backslash-traversal
Sep 8, 2026
Merged

jaraco merged 4 commits into
mainfrom
fix/archive-util-backslash-traversal

Conversation

@jaraco

@jaraco jaraco commented Sep 7, 2026

Copy link
Copy Markdown
Member

Summary

setuptools.archive_util guarded against traversal with:

if name.startswith('/') or '..' in name.split('/'):
    continue
target = os.path.join(extract_dir, *name.split('/'))

Both the tar and zip formats specify / as the only path separator, so splitting on / alone is the natural reading — but a backslash is then carried into a single path component and handed to os.path.join, where Windows treats it as a separator after all:

member name old guard blocked? resolved destination
..\escaped.txt no C:\escaped.txt
safe\..\escaped.txt no C:\dest\escaped.txt
C:evil.txt no C:\evil.txt
\\server\share\x no \\server\share\x

The drive-qualified and UNC cases are not traversal at all — os.path.join discards extract_dir outright, giving a fully attacker-chosen destination.

This affects the zip driver as well as the tar driver: _unpack_zipfile_obj carries a byte-identical guard. A zipfile-built PoC hides this, because ZipInfo.__init__ rewrites os.sep to / when os.sep != "/", so an archive built on Windows has its backslashes normalized away; one built on Linux (or written directly) preserves them.

Ref GHSA-grgh-hr87-3jpw, reported by @Faze-up.

Impact

Low, hence a public PR rather than a private fix.

The tar driver has had no in-tree caller since easy_install and package_index were removed — install_egg_info passes a locally built .egg-info directory, which dispatches to unpack_directory. So the reported vector is third-party callers of the public API only.

The zip driver is still reachable in-tree, via installer._fetch_build_egg_no_warn → Wheel.install_as_egg → _unpack_zipfile_obj, on the deprecated setup_requires path. Even there the escalation is slight: a wheel unpacked by that path is about to be imported anyway.

Approach

Both drivers now share one _resolve_dest(), so the two copies of the guard cannot drift apart again. It rejects names containing a backslash, .. components, a leading /, or a drive/UNC prefix, and then — belt and braces — confirms the resolved destination really does fall under the resolved extraction directory.

Two details worth flagging for review:

  • The containment check is applied to the preliminary destination, before progress_filter. The callback is documented as free to rewrite the destination, so asserting on the post-filter path would break filters that legitimately relocate files.
  • Zip directory members keep their trailing separator. ensure_directory only creates the parent of its argument, so dropping it would leave empty directories uncreated — there is now a regression test pinning that.

Tests

Traversal cases are parametrized over both drivers and asserted through unpack_archive, so they are meaningful on Linux CI rather than Windows-only: on POSIX the old code created a file literally named ..\escaped.txt inside the target, so "the target contains only the control member" fails before the fix on every platform. Archive members are written with the name assigned after ZipInfo construction, so the backslash cases survive being built on Windows too.

There is also a symlink case covering the containment check specifically: a member name that is harmless in isolation still must not escape through a symlink already present in the extraction directory.

🤖 Generated with Claude Code

Both the tar and zip formats specify '/' as the only path separator, but
the guard in archive_util split member names on '/' alone. A name such as
`..\escaped.txt` was therefore a single component that passed the '..'
check, and was then resolved as a traversal by the filesystem on Windows.
Drive-qualified and UNC names escaped the same way, discarding the
extraction directory entirely.

Both drivers now share a single _resolve_dest() that rejects such names
and, as defense in depth, confirms the resolved destination really does
fall under the extraction directory.

Ref GHSA-grgh-hr87-3jpw.

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

mergify Bot commented Sep 7, 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

jaraco and others added 2 commits September 7, 2026 10:13
os.path.commonpath raises ValueError only for paths on different drives,
which the drive-qualified name check already rejects, leaving a branch that
cannot be exercised. Compare against the root prefix instead; joining an
empty component keeps the comparison correct when extracting to the root of
a filesystem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Returning a sentinel left each call site to interpret it, and made a
malicious or malformed archive indistinguishable from a clean one: the
caller got a quietly incomplete extraction with no indication that
anything had been dropped. For the one remaining in-tree caller, an
unpacked wheel, that means a corrupt install that is then imported.

Raise instead, as the stdlib tarfile extraction filters do. UnsafeMember
is deliberately not an UnrecognizedFormat, which unpack_archive catches
to fall through to the next driver.

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

jaraco commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Switched _resolve_dest from returning None to raising a new UnsafeMember, per review. Rationale, since it changes public behavior:

Skipping made a malicious or malformed archive indistinguishable from a clean one — the caller got a quietly incomplete extraction with no signal that anything was dropped. For the one remaining in-tree caller that is an unpacked wheel, i.e. a corrupt install that is then imported. The stdlib made the same call in PEP 706: data_filter raises OutsideDestinationError rather than skipping, leaving "skip it" to callers who write their own filter.

UnsafeMember is deliberately not a subclass of UnrecognizedFormat. unpack_archive catches that one to fall through to the next driver, so inheriting from it would have converted an unsafe zip into an attempt to parse the same file as a tar, and ultimately into a misleading "Not a recognized archive type".

Two consequences worth a look before merge:

  • This is a breaking change, so there is now a removal fragment alongside the bugfix one. Previously an archive containing a .. or absolute member extracted everything else and returned normally; it now aborts.
  • The blast radius is wider than the traversal cases. Some non-conforming Windows archivers write dir\file.txt as a member name. Under the old code that extracted as a literal backslash-named file on POSIX, and as dir/file.txt on Windows — i.e. it "worked" there. Those archives now raise. I think that is correct, since we cannot distinguish a buggy archiver from an attacker by the name alone, but it is a real behavior change for benign inputs and worth your call.

Also added a positive test that ordinary members — nested and ./-prefixed — still extract, so the guard is pinned against over-rejection in both directions.

Silently skipping members that escape the extraction directory was never
intended behavior, so there is nothing supported being removed. Nor is
rejecting backslash-separated member names: both formats specify '/' as
the only separator, the old handling was already inconsistent between
Windows and POSIX, and nothing in the module's history suggests anyone
relied on it.

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

jaraco commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

Correcting my previous comment: the removal fragment is gone, replaced by a feature one. Silently skipping members that escape the extraction directory was never intended behavior, so nothing supported is being removed.

Backslash-separated member names are likewise staying rejected on every platform, with no removal marker, on the reasoning that no good-faith use case could have depended on them:

  • Both formats specify / as the only separator (ZIP APPNOTE 4.4.17.1, POSIX ustar), so such a name is malformed by specification rather than an alternative convention.
  • The old handling was already inconsistent across platforms — the same archive yielded a nested tree on Windows and a single literal backslash-named file on POSIX — so no portable code could have relied on it.
  • The only in-tree consumer is unpacked wheels, and zipfile rewrites os.sep to / at write time, so conforming tooling cannot produce such a wheel in the first place.
  • Nothing in the module's history — no issue, PR, or changelog entry across its ~20 years — mentions backslash or separator handling.

Rejecting uniformly also removes the platform-dependent behavior rather than entrenching it, which is the outcome we want.

@jaraco
jaraco merged commit 2707b4e into main Sep 8, 2026
72 of 90 checks passed
@jaraco
jaraco deleted the fix/archive-util-backslash-traversal branch September 8, 2026 14:58
@jaraco

jaraco commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

Split out the two things noted during review but left out of scope here:

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.

1 participant