ZIP extraction looks like a single filesystem operation, but an archive is really a collection of filenames, metadata, and compressed byte streams supplied by whoever created the file. When the archive is untrusted, that metadata belongs at a trust boundary.

Python’s zipfile module provides convenient extraction helpers, and those helpers include protections for suspicious path components. The documentation still warns against extracting untrusted archives without prior inspection. That distinction is useful: library normalization is not the same thing as an application-specific acceptance policy.

A robust importer should decide what an acceptable archive looks like before allowing it to populate a destination directory.

Start by separating validity from policy

ZipFile.testzip() checks archive members by reading them and verifying their CRC values. That can detect damaged data, but a structurally valid archive can still be unacceptable to your application.

For example, a valid archive might contain:

  • more files than your service is willing to create,
  • a total uncompressed size larger than your disk budget,
  • filenames your application does not allow,
  • duplicate logical paths,
  • entries outside the directory layout your importer expects.

So this is not a complete security check:

from zipfile import ZipFile

with ZipFile("upload.zip") as archive:
    if archive.testzip() is None:
        archive.extractall("output")

CRC verification answers a data-integrity question. It does not define your extraction policy.

Inspect ZipInfo records before writing files

ZipFile.infolist() returns a ZipInfo object for each member. That gives you a useful pre-extraction view of names and declared sizes.

from zipfile import ZipFile

with ZipFile("upload.zip") as archive:
    for info in archive.infolist():
        print(info.filename, info.file_size, info.compress_size)

This is the right stage to reject an archive before normal extraction begins.

A basic policy can constrain member count and total declared uncompressed size:

from zipfile import ZipFile

MAX_FILES = 2_000
MAX_TOTAL_SIZE = 500 * 1024 * 1024


def validate_budget(archive: ZipFile) -> None:
    members = archive.infolist()

    if len(members) > MAX_FILES:
        raise ValueError("archive contains too many entries")

    total_size = sum(info.file_size for info in members)
    if total_size > MAX_TOTAL_SIZE:
        raise ValueError("archive is too large when uncompressed")

The numbers are examples, not universal safe values. Set limits according to the storage, latency, and workload constraints of the system doing the extraction.

Reject path shapes your application does not need

If your archive format only needs relative POSIX-style member names, enforce that rule explicitly rather than accepting every shape ZIP can represent.

PurePosixPath is useful because ZIP member names use forward-slash path syntax regardless of the host operating system.

from pathlib import PurePosixPath
from zipfile import ZipInfo


def validate_member_name(info: ZipInfo) -> None:
    name = info.filename
    path = PurePosixPath(name)

    if not name:
        raise ValueError("empty member name")

    if path.is_absolute():
        raise ValueError(f"absolute archive path: {name!r}")

    if ".." in path.parts:
        raise ValueError(f"parent traversal in archive path: {name!r}")

    if "\\" in name:
        raise ValueError(f"backslash in archive path: {name!r}")

Rejecting backslashes is intentionally conservative. If your archive format controls its own naming convention, accepting only one separator removes cross-platform ambiguity instead of trying to reinterpret every possible filename.

You may need additional rules. For example, an importer could permit only images/ and metadata.json, or reject names containing control characters. The important point is to define the format you accept, not merely blacklist a few famous malicious strings.

Detect duplicate destination names

Two entries can target the same logical path. Even when that does not escape the destination directory, overwrite behavior can make the result depend on archive order.

Reject duplicates when your format expects one member per path:

from pathlib import PurePosixPath
from zipfile import ZipFile


def validate_unique_names(archive: ZipFile) -> None:
    seen: set[PurePosixPath] = set()

    for info in archive.infolist():
        path = PurePosixPath(info.filename)

        if path in seen:
            raise ValueError(f"duplicate archive member: {info.filename!r}")

        seen.add(path)

Depending on your target platforms, you may need a stricter collision policy. Filesystems can differ in case sensitivity and filename normalization. If an archive will be processed on multiple platforms, define the naming contract at the application level rather than assuming every filesystem treats names identically.

Combine validation into one preflight pass

A practical validator can perform the cheap metadata checks together:

from pathlib import PurePosixPath
from zipfile import ZipFile

MAX_FILES = 2_000
MAX_MEMBER_SIZE = 100 * 1024 * 1024
MAX_TOTAL_SIZE = 500 * 1024 * 1024


def validate_archive(archive: ZipFile) -> None:
    members = archive.infolist()

    if len(members) > MAX_FILES:
        raise ValueError("too many archive entries")

    seen: set[PurePosixPath] = set()
    total_size = 0

    for info in members:
        name = info.filename
        path = PurePosixPath(name)

        if not name or path.is_absolute() or ".." in path.parts or "\\" in name:
            raise ValueError(f"invalid archive member: {name!r}")

        if path in seen:
            raise ValueError(f"duplicate archive member: {name!r}")
        seen.add(path)

        if info.file_size > MAX_MEMBER_SIZE:
            raise ValueError(f"archive member is too large: {name!r}")

        total_size += info.file_size
        if total_size > MAX_TOTAL_SIZE:
            raise ValueError("archive exceeds total uncompressed size limit")

Then extraction happens only after the whole metadata set has passed:

from pathlib import Path
from zipfile import ZipFile


def import_zip(source: Path, destination: Path) -> None:
    with ZipFile(source) as archive:
        validate_archive(archive)
        archive.extractall(destination)

This ordering prevents a later metadata rejection from occurring after earlier members have already been extracted.

It does not make extraction transactional. An I/O error, process termination, full filesystem, corrupt member, or other failure can still leave a partially populated destination.

Extract into a staging directory when partial output matters

If callers must not observe a half-extracted import, do not extract directly into the final live directory.

A common design is:

  1. create a private staging directory,
  2. validate the archive,
  3. extract into staging,
  4. perform any content-level validation,
  5. publish the completed result using a filesystem operation appropriate for your platform and deployment model.

For example:

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


def prepare_zip(source: Path) -> None:
    with TemporaryDirectory() as temp_dir:
        staging = Path(temp_dir)

        with ZipFile(source) as archive:
            validate_archive(archive)
            archive.extractall(staging)

        validate_extracted_content(staging)
        publish(staging)

The exact publish() operation is application-specific. Do not assume moving a directory gives universal transactional semantics across filesystems, operating systems, or existing destinations.

The staging pattern still provides an important boundary: failed extraction does not directly mix partial files into the live destination.

Declared sizes are useful limits, not a complete quota mechanism

ZipInfo.file_size describes the member’s uncompressed size, while compress_size describes its compressed size. A very large difference can be a sign that extraction will consume much more disk space than the archive file itself suggests.

Checking declared uncompressed sizes before extraction is therefore useful, but resource control should not rely on metadata alone when the consequences of exceeding a limit are serious.

Your environment can impose additional constraints outside Python: filesystem quotas, container storage limits, isolated worker directories, request-size limits, timeouts, or process-level resource controls. Defense in depth matters because decompression consumes more than just final file bytes; it also consumes CPU, temporary resources, directory entries, and time.

Avoid inventing a universal compression-ratio cutoff. Highly compressible legitimate data can have an extreme ratio. File count, per-member size, total expanded size, and workload-specific expectations are usually easier policies to explain and test.

zipfile.Path has a different safety boundary

zipfile.Path provides a pathlib-like interface for navigating inside an archive. It is convenient for reading selected members without extracting the whole archive.

But its documentation explicitly notes that it does not sanitize archive filenames. If you use archive-controlled names from zipfile.Path to construct filesystem destinations yourself, path containment becomes your responsibility.

This means code like this deserves careful review:

for child in archive_path.iterdir():
    destination = output_dir / child.name
    # copy child to destination

The safety properties of ZipFile.extract() and extractall() should not be assumed to apply to a separate manual extraction implementation.

If you manually map archive names to filesystem paths, validate the names and verify that the resolved destination remains within the intended extraction root before writing.

Do not confuse archive inspection with content validation

Metadata checks cannot tell you whether a file is semantically safe for your application.

An archive may pass every path and size rule but still contain an invalid image, a malicious document, an unexpected executable, or configuration that violates your application’s schema.

Keep the stages separate:

archive format
    -> metadata policy
    -> extraction into staging
    -> content validation
    -> publication

Each stage answers a different question. Combining them into a vague is_safe_zip() function tends to hide which guarantees actually exist.

Test adversarial boundaries explicitly

Archive validators are policy code, so tests should cover rejection cases as deliberately as successful imports.

Useful cases include:

  • an empty archive,
  • an archive at exactly the maximum file count,
  • one entry beyond that maximum,
  • one member at the per-file size limit,
  • total declared size just above the limit,
  • ../outside.txt,
  • /absolute.txt,
  • names containing backslashes,
  • duplicate member names,
  • nested valid directories,
  • a corrupt archive or corrupt member.

You can construct most fixtures in memory:

from io import BytesIO
from zipfile import ZipFile


def make_zip(entries: dict[str, bytes]) -> BytesIO:
    buffer = BytesIO()

    with ZipFile(buffer, "w") as archive:
        for name, data in entries.items():
            archive.writestr(name, data)

    buffer.seek(0)
    return buffer

Then test the policy without touching the real filesystem:

import pytest
from zipfile import ZipFile


def test_rejects_parent_traversal() -> None:
    source = make_zip({"../outside.txt": b"no"})

    with ZipFile(source) as archive:
        with pytest.raises(ValueError):
            validate_archive(archive)

For extraction behavior itself, use a temporary directory and assert exactly which files were created. Tests that only assert “an exception happened” can miss unwanted writes that occurred before the exception.

Conclusion

Python’s ZIP helpers make archive handling straightforward, but extraction is still a boundary between archive-controlled metadata and your filesystem. Treating extractall() as the entire import policy leaves important application constraints unstated.

Inspect ZipInfo metadata first. Define allowed path shapes, reject duplicates when ordering should not control overwrites, and enforce file-count and expanded-size budgets that fit your service. When partial output is unacceptable, extract into staging before publishing the result.

Most importantly, keep the guarantees distinct. CRC checks validate stored data, metadata validation enforces archive policy, extraction writes files, and content validation decides whether those files are acceptable to the application. A secure and maintainable importer makes each boundary explicit.