Skip to content

fix(security): close validate/extract path mismatch in pathsec Zip-Slip guard (CWE-22) - #3634

Merged
ekaf merged 2 commits into
nltk:developfrom
LinZiyuu:fix/pathsec-zipslip-mismatch
Jun 25, 2026
Merged

ekaf merged 2 commits into
nltk:developfrom
LinZiyuu:fix/pathsec-zipslip-mismatch

Conversation

@LinZiyuu

Copy link
Copy Markdown
Contributor

Fix Zip-Slip via validate/extract path-normalization mismatch in nltk.pathsec (CWE-22 / CWE-59)

Description

nltk.pathsec.ZipFile is the hardened ZIP extractor used across NLTK. For each
member it calls the centralized validator validate_zip_archive() to enforce
that 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 is
written.

  • validate_zip_archive computes member_path = (target / name_str).resolve(),
    and Path.resolve() collapses a .. against its preceding component:
    a/../b/evil.txt → <root>/b/evil.txt.
  • zipfile.ZipFile.extract instead drops the empty / . / .. components
    and 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/b is
a 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_archive is the centralized Zip-Slip sentinel, the mismatch
affects every caller of pathsec.ZipFile.extract / .extractall, not a
single 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 different
normalizations in lock-step, reject those members outright:

def _zip_member_is_unsafe(name_str):
    normalized = name_str.replace("\\", "/") if os.path.altsep else name_str
    if os.path.splitdrive(name_str)[0] or normalized.startswith("/"):
        return True
    return os.path.pardir in normalized.split("/")
member_path = (target / name_str).resolve()
if _zip_member_is_unsafe(name_str) or not (
    member_path == target or member_path.is_relative_to(target)
):
    # ... raise PermissionError (ENFORCE) / warn

This keeps the existing symlink-resolving containment check — which already
catches a pure-symlink escape such as a/b/x where <root>/a/b is a symlink
(no .. needed) — and adds the component check that closes the .. / absolute
gap. The two together guarantee the validated decision matches the written
location.

Behaviour

Member Before After
a/../b/evil.txt (with <root>/a/b → outside symlink) escapes, no alert blocked (PermissionError)
../../../evil.sh blocked blocked
/etc/cron.d/evil_cron blocked blocked
a/b/x (with <root>/a/b symlink → outside) blocked (resolve) blocked (resolve)
corpus/data/file.txt (benign nested) extracted extracted (no false positive)

Reproduction

poc_validate_zip_archive_mismatch.py plants <root>/a/b → outside, builds an
archive whose member is a/../b/evil.txt, and calls
pathsec.ZipFile.extract(...):

  • Before: validate_zip_archive raises nothing and
    <outside>/evil.txt is created with b"PWNED-OUTSIDE-EXTRACTION-ROOT".
  • After: 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 members
    still 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 the
    pre-existing test_zip_slip_traversal / test_zip_slip_absolute_path).
  • black==25.11.0 and ruff==0.15.6 (repo-pinned) — clean.

…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 between Path.resolve() (validation) and zipfile.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.

Comment thread nltk/pathsec.py Resolved
…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.)
@LinZiyuu

Copy link
Copy Markdown
Contributor Author

Good suggestion — added two tests to nltk/test/unit/test_pathsec.py:

  • test_zip_slip_interior_dotdot — asserts pathsec.ZipFile.extractall() rejects a member a/../b/evil.txt under ENFORCE=True.
  • test_zip_slip_interior_dotdot_symlink_escape — plants an in-root symlink <root>/a/b -> outside, asserts the member is rejected and nothing is written outside the extraction root.

Both fail (DID NOT RAISE) against the pre-fix validator and pass after the fix, so they guard this specific normalization mismatch against regression. Full test_pathsec.py is green (35 passed).

@ekaf ekaf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ekaf
ekaf merged commit 1b308cb into nltk:develop Jun 25, 2026
22 checks passed
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.

3 participants