Repository navigation
fix(security): close validate/extract path mismatch in pathsec Zip-Slip guard (CWE-22) - #3634
Conversation
…ip guard (CWE-22) validate_zip_archive (the centralized Zip-Slip sentinel used by pathsec.ZipFile.extract/extractall) validated `(target / name).resolve()`, which collapses a ".." against its preceding component, while zipfile.ZipFile.extract drops "."/".."/empty components and keeps the rest. For a member like "a/../b/evil.txt" the validator inspects <root>/b/evil.txt (allowed) but the extractor writes <root>/a/b/evil.txt; if <root>/a/b is a pre-existing symlink pointing outside the extraction root, the write follows it and escapes with attacker-controlled content, and the centralized check raised nothing. Every caller of the hardened extractor was affected (CWE-22 / CWE-59). The divergence only ever arises from absolute, drive-qualified, or ".."-bearing members -- shapes a legitimate archive never uses and that the hardened extractor already promises to proactively block. Reject them outright via _zip_member_is_unsafe(), keeping the existing symlink-resolving containment check (which still catches pure-symlink escapes such as "a/b/x" through a symlinked <root>/a/b). The validated decision now matches the written location. Verified: the PoC escape is blocked with no out-of-root write; classic "../" and absolute members stay blocked; benign nested archives extract fully (no false positives); 58 security unit tests pass, including the pre-existing test_zip_slip_traversal / test_zip_slip_absolute_path.
There was a problem hiding this comment.
Pull request overview
This PR hardens NLTK’s centralized ZIP extraction guard (nltk.pathsec.ZipFile / validate_zip_archive) against a Zip-Slip variant caused by a validate-vs-extract path-normalization mismatch (CWE-22 / CWE-59), especially when pre-existing symlinks are involved.
Changes:
- Added
_zip_member_is_unsafe()to proactively reject ZIP member names that can normalize differently betweenPath.resolve()(validation) andzipfile.ZipFile.extract()(write). - Updated
validate_zip_archive()to block absolute, drive-qualified, or..-bearing members before allowing extraction.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…E-59) Adds two tests to test_pathsec.py covering the validate/extract normalization mismatch this PR fixes, which the existing tests (leading "../" and absolute paths) did not exercise: - test_zip_slip_interior_dotdot: a member "a/../b/evil.txt" must be rejected by pathsec.ZipFile.extractall() under ENFORCE. - test_zip_slip_interior_dotdot_symlink_escape: with an in-root symlink <root>/a/b -> outside, the member must be rejected and nothing written outside the extraction root. Both fail (DID NOT RAISE) against the pre-fix validator and pass after it. (Addresses the PR review suggestion.)
|
Good suggestion — added two tests to
Both fail ( |
ekaf
left a comment
There was a problem hiding this comment.
This appears to close a real path-validation gap rather than just papering over a symptom, and the change is properly placed in the ZIP guard layer where it can protect all extraction call sites. Because it addresses a file-write / Zip-Slip bypass with broad impact on NLTK’s downloader and archive handling.
Fix Zip-Slip via validate/extract path-normalization mismatch in
nltk.pathsec(CWE-22 / CWE-59)Description
nltk.pathsec.ZipFileis the hardened ZIP extractor used across NLTK. For eachmember it calls the centralized validator
validate_zip_archive()to enforcethat the member stays within the extraction root, then calls the standard-library
extractor to write the file. The two steps normalize a member path containing a
..component differently, so the path that is validated is not the path that iswritten.
validate_zip_archivecomputesmember_path = (target / name_str).resolve(),and
Path.resolve()collapses a..against its preceding component:a/../b/evil.txt→<root>/b/evil.txt.zipfile.ZipFile.extractinstead drops the empty /./..componentsand keeps the rest:
a/../b/evil.txt→<root>/a/b/evil.txt.So the validator inspects
<root>/b/evil.txt(not a symlink → "contained" →allowed), while the extractor writes to
<root>/a/b/evil.txt. If<root>/a/bisa pre-existing symlink pointing outside the extraction root, the write
follows the symlink and lands outside the root with fully attacker-controlled
content, while the centralized Zip-Slip check raised nothing.
Because
validate_zip_archiveis the centralized Zip-Slip sentinel, the mismatchaffects every caller of
pathsec.ZipFile.extract/.extractall, not asingle extraction site.
The fix
The divergence only ever arises from absolute, drive-qualified, or
..-bearing member names — the exact shapes a legitimate archive never uses,and the shapes the hardened extractor already promises to proactively block
(see
nltk/test/unit/test_pathsec.py::test_zip_slip_traversal/::test_zip_slip_absolute_path). Rather than try to keep two differentnormalizations in lock-step, reject those members outright:
This keeps the existing symlink-resolving containment check — which already
catches a pure-symlink escape such as
a/b/xwhere<root>/a/bis a symlink(no
..needed) — and adds the component check that closes the../ absolutegap. The two together guarantee the validated decision matches the written
location.
Behaviour
a/../b/evil.txt(with<root>/a/b→ outside symlink)../../../evil.sh/etc/cron.d/evil_crona/b/x(with<root>/a/bsymlink → outside)corpus/data/file.txt(benign nested)Reproduction
poc_validate_zip_archive_mismatch.pyplants<root>/a/b → outside, builds anarchive whose member is
a/../b/evil.txt, and callspathsec.ZipFile.extract(...):validate_zip_archiveraises nothing and<outside>/evil.txtis created withb"PWNED-OUTSIDE-EXTRACTION-ROOT".PermissionError: Security Violation [ZipAudit]: Traversal member 'a/../b/evil.txt' detected.and nothing is written outside the root.Testing
poc_validate_zip_archive_mismatch.py— escape now blocked, no out-of-root write.verify_pathsec_fix.py— escape blocked; classic../and absolute membersstill blocked; benign nested archives extract fully (no false positives).
pytest nltk/test/unit/test_pathsec.py nltk/test/unit/test_nkjp_security.py nltk/test/unit/test_framenet_security.py nltk/test/unit/test_downloader_atomic.py nltk/test/unit/test_corpus_reader.py— 58 passed (this includes thepre-existing
test_zip_slip_traversal/test_zip_slip_absolute_path).black==25.11.0andruff==0.15.6(repo-pinned) — clean.