Skip to content

Refuse to extract TAR/ZIP archives with path-traversing members - #198

Open
akshar27 wants to merge 1 commit into
wummel:mainfrom
akshar27:fix/archive-extraction-path-traversal
Open

akshar27 wants to merge 1 commit into
wummel:mainfrom
akshar27:fix/archive-extraction-path-traversal

Conversation

@akshar27

@akshar27 akshar27 commented Sep 7, 2026

Copy link
Copy Markdown

Summary

Following up on the maintainer's suggested approach in the issue ("list all members of an archive before extraction and inspect them" / "extract only inspected and safe members"), this implements a central guard for the two formats patool can inspect uniformly regardless of backend: TAR and ZIP.

patoolib.util.get_unsafe_archive_member(format, archive, outdir) lists a TAR/ZIP archive's members with Python's own tarfile/zipfile modules (list-only, no extraction) and checks whether any member's path would resolve outside of outdir (via .. components or an absolute path). _extract_archive() calls this before dispatching to any extraction backend — external program or native module — and raises PatoolError (reject, not strip-and-continue) if an unsafe member is found, so nothing gets written at all rather than a partially-sanitized extraction.

Why TAR/ZIP only, for now: those are the only two formats where patool can list members with a single, uniform stdlib API regardless of which actual backend the user has installed (7z, GNU tar, star, bsdtar, unzip, unar, jar, ...). The other ~13 formats each have their own listing quirks per external program, so a uniform guard for all of them is a separate, larger effort I didn't want to bundle into one PR. They're unchanged and still rely solely on their backend's own protections, exactly as documented in SECURITY.md today.

Fail-open on unlistable archives: if the stdlib module can't even open the archive (eg. a .tar.Z, or a .tar.zst on Python < 3.14, both of which an external tar binary handles fine but the stdlib tarfile module can't), the check is skipped and extraction proceeds as before. I want to flag this explicitly for review: I chose compatibility over "fail closed" here, since breaking working extraction for a compression variant is a real regression a security check shouldn't introduce as a side effect. Happy to discuss if a stricter default is preferred.

Fixes #193

Testing

New file tests/test_extract_security.py:

  • Integration-level: builds a real malicious TAR and ZIP archive (both ../ relative traversal and absolute-path variants) with a crafted member name, asserts patoolib.extract_archive() raises PatoolError and nothing is written outside the output directory. Also asserts a normal (safe) archive still extracts correctly (regression guard).
  • Unit-level: exercises util.get_unsafe_archive_member() directly for both formats, plus confirms an unchecked format (7z) returns None.

Ran locally (Python 3.12, since pyproject.toml requires >=3.12 and my system default was 3.11):

  • pytest tests/ — 110 passed, 132 skipped (skipped tests need external programs not installed here, eg. 7z/unrar). 3 pre-existing failures unrelated to this change and reproducible on unmodified main (missing Java runtime for jar; system zip lacking password support) — confirmed via git stash before attributing them.
  • ruff check / ruff format --check (pinned 0.16.5) — clean.
  • ty check (pinned 0.0.75) — same 2 pre-existing diagnostics as unmodified main (missing optional argcomplete/setuptools in my minimal venv), confirmed via git stash -u; no new diagnostics from this change.

I also manually verified the underlying vulnerability class against the real system tar/unzip binaries directly (not through patool) to understand what's actually exploitable: on this machine (macOS, bsdtar + Info-ZIP unzip) both already refuse/strip ../ on their own, which is why the fix here is backend-independent rather than relying on any specific tool's protections — GNU tar on Linux (patool's most common deployment target) does not have the same built-in guard.

Notes

  • GitHub Actions doesn't appear to be enabled yet on my fork (akshar27/patool) — a one-time manual step outside this PR. I'll push a retrigger commit once it's on, if CI doesn't pick this PR up.

patool has no central sanitization of '..' relative path components (or
absolute paths) in TAR/ZIP archive member names. The tempdir used for
extraction is created in the current working directory, so a single
'../' in a member name already reaches the CWD, and further '../'
components climb into ancestor directories. Not every backend archive
program guards against this on its own (eg. GNU tar on Linux does not
by default), so a malicious archive could write files outside the
requested extraction directory.

Add util.get_unsafe_archive_member(), which lists a TAR or ZIP
archive's members with Python's own tarfile/zipfile modules (without
extracting) and checks whether any member would resolve outside of the
extraction directory. _extract_archive() now calls this before
dispatching to any extraction backend (external program or native
module) and refuses to extract at all if an unsafe member is found.

This intentionally covers only TAR and ZIP, since those are the two
formats patool can list uniformly via the Python standard library
regardless of which extraction backend ends up being used. Other
archive formats are unchanged and still rely solely on their backend
program's own protections, as documented in SECURITY.md.

If the archive can't be listed at all (eg. a .tar.Z or pre-3.14 .tar.zst
that the stdlib tarfile module can't open, though an external tar
command can), the check is skipped and extraction proceeds as before -
this fix must not regress support for compression variants the stdlib
doesn't handle itself.

Fixes wummel#193
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.

No defense against ../ relative path traversal during extraction

1 participant