Repository navigation
Reject archive members that escape the extraction directory - #5325
Conversation
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>
|
Tick the box to add this pull request to the merge queue (same as
|
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>
|
Switched 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:
Two consequences worth a look before merge:
Also added a positive test that ordinary members — nested and |
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>
|
Correcting my previous comment: the Backslash-separated member names are likewise staying rejected on every platform, with no
Rejecting uniformly also removes the platform-dependent behavior rather than entrenching it, which is the outcome we want. |
|
Split out the two things noted during review but left out of scope here:
|
Summary
setuptools.archive_utilguarded against traversal with: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 toos.path.join, where Windows treats it as a separator after all:..\escaped.txtC:\escaped.txtsafe\..\escaped.txtC:\dest\escaped.txtC:evil.txtC:\evil.txt\\server\share\x\\server\share\xThe drive-qualified and UNC cases are not traversal at all —
os.path.joindiscardsextract_diroutright, giving a fully attacker-chosen destination.This affects the zip driver as well as the tar driver:
_unpack_zipfile_objcarries a byte-identical guard. Azipfile-built PoC hides this, becauseZipInfo.__init__rewritesos.septo/whenos.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_installandpackage_indexwere removed —install_egg_infopasses a locally built.egg-infodirectory, which dispatches tounpack_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 deprecatedsetup_requirespath. 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:
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.ensure_directoryonly 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.txtinside 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 afterZipInfoconstruction, 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