Reject unsafe ZIP member names before extraction

Member names need validation before an application decides what to extract. This example writes three deliberately chosen names to a temporary archive, then reads only their names with namelist(). is_safe_member_name() normalizes backslashes to forward slashes so that Windows-style separators cannot hide traversal components. It rejects names containing a null character, absolute paths, simple drive-prefixed paths such as C:/..., and paths with a .. component.

The resulting policy accepts reports/today.txt and rejects ../outside.txt plus C:\\temp\\drive.txt. Crucially, the program does not call extract() or extractall(); filtering happens before an extraction decision. The assertions verify the policy result for these three synthetic inputs, rather than claiming that the function makes arbitrary archives safe.

This is intentionally a narrow name policy. It does not inspect file contents, compression ratios, duplicate names, file types, permissions, symbolic-link behavior, or resource limits. A production extractor also needs a controlled destination, size limits, error handling, and policies appropriate to its platform. Python’s documentation warns against extracting untrusted archives without prior inspection, even though its extraction methods attempt filename sanitization. The example requires Python 3.6.2+ because it passes pathlib.Path to ZipFile; none of the used validation APIs are newer ZIP features.

Read the official ZipFile.extractall warning and ZipFile.namelist documentation.

AI-assistance disclosure: This article was drafted with AI assistance and should be reviewed for its intended use.

from pathlib import Path, PurePosixPath
from tempfile import TemporaryDirectory
from zipfile import ZipFile


def is_safe_member_name(name):
    normalized = name.replace("\\", "/")
    path = PurePosixPath(normalized)
    return (
        "\x00" not in name
        and not normalized.startswith("/")
        and not (len(normalized) >= 2 and normalized[1] == ":")
        and ".." not in path.parts
    )


with TemporaryDirectory() as directory:
    archive_path = Path(directory) / "incoming.zip"

    with ZipFile(archive_path, "w") as archive:
        archive.writestr("reports/today.txt", "safe\n")
        archive.writestr("../outside.txt", "unsafe\n")
        archive.writestr("C:\\temp\\drive.txt", "unsafe\n")

    with ZipFile(archive_path) as archive:
        names = archive.namelist()

    accepted = [name for name in names if is_safe_member_name(name)]
    rejected = [name for name in names if not is_safe_member_name(name)]

    assert accepted == ["reports/today.txt"]
    assert rejected == ["../outside.txt", "C:\\temp\\drive.txt"]
    print("accepted:", ", ".join(accepted))
    print("rejected:", ", ".join(rejected))
accepted: reports/today.txt
rejected: ../outside.txt, C:\temp\drive.txt

Comments

Popular posts from this blog

Compare Two Text Files in Python Without Modifying Them

Seven CSV Quality Checks to Run Before Importing Data

Explain why SQLite transactions cannot make an HTTP call atomic