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
Post a Comment