Unsafe archive extraction and Zip Slip¶
Archive filenames, entry types, counts, and expanded sizes are untrusted. An extractor that combines the destination directory with an unchecked entry name may write outside that directory. Symbolic links and filesystem races add further risk. A tiny compressed upload can also consume excessive memory, disk, or processing time after expansion.
Trust boundary: an archive supplies file contents; it must not choose arbitrary host paths or resource consumption. Behavior varies between formats and extraction APIs. The Python zipfile documentation warns about untrusted archives and discusses resource limits; do not assume every library handles unsafe names identically.
Unsafe example: manual extraction with unchecked names¶
for entry in archive.infolist():
target = destination / entry.filename
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(archive.read(entry))
Safer example: a deliberately flat ZIP policy¶
This Python 3.10+ helper permits only regular files with simple names and no subdirectories. It creates its own private temporary directory, counts actual expanded bytes, rejects duplicate names, and removes partial output on failure.
import io
import re
import shutil
import stat
import tempfile
import zipfile
from pathlib import Path
MAX_INPUT = 2 * 1024 * 1024
MAX_FILE = 1 * 1024 * 1024
MAX_TOTAL = 5 * 1024 * 1024
def extract_flat_zip(data: bytes) -> Path:
if len(data) > MAX_INPUT:
raise ValueError("Archive too large")
root = Path(tempfile.mkdtemp(prefix="attachment-"))
try:
with zipfile.ZipFile(io.BytesIO(data)) as archive:
entries = archive.infolist()
if len(entries) > 40:
raise ValueError("Too many entries")
total = 0
for entry in entries:
name = entry.filename
kind = stat.S_IFMT(entry.external_attr >> 16)
if (not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,95}", name)
or kind not in (0, stat.S_IFREG)
or entry.is_dir() or entry.flag_bits & 1
or entry.file_size > MAX_FILE):
raise ValueError("Unsupported archive entry")
size = 0
with archive.open(entry) as source, (root / name).open("xb") as out:
while chunk := source.read(64 * 1024):
size += len(chunk)
total += len(chunk)
if size > MAX_FILE or total > MAX_TOTAL:
raise ValueError("Expanded size limit exceeded")
out.write(chunk)
return root
except Exception:
shutil.rmtree(root)
raise
The policy rejects slashes, backslashes, drive prefixes, dot-only names, links, and encrypted entries rather than trying to normalize them. Exclusive creation catches duplicates and name collisions. The private directory must remain inaccessible to untrusted local processes; otherwise filename checks alone cannot prevent symlink races. The caller owns cleanup of the returned directory and must validate each file before publishing or processing it.
This is not a general-purpose archive service. Enforce upload limits before buffering data, use a maintained runtime, and run extraction with CPU, time, disk, and concurrency limits. Expanded-byte checks do not bound every decompressor's CPU cost. Nested archives remain ordinary files and must not be extracted recursively without another explicit budget.
Regression test¶
Generate small ZIP fixtures locally. Accept report.txt; reject ../report.txt, an absolute name, a directory, duplicate names, a marked symbolic link, an oversized file, and 41 entries. Assert failed extraction leaves no partial directory. Test exact-limit boundaries without producing a large decompression bomb.
Related: file uploads, path traversal, and resource exhaustion.
References: CWE-22 — path traversal and CWE-409 — highly compressed data.