zipfile и tarfile в Python: рабочие приёмы и грабли
Как читать, создавать и распаковывать архивы стандартными модулями Python без загрузки файла в память целиком и без уязвимости path traversal. Разбираем разницу zipfile и tarfile и когда что выбрать.

Задача звучит просто: распаковать пользовательский архив на сервере. Наивный код zipfile.ZipFile(f).extractall() работает на ваших тестовых файлах и падает или, хуже, перезаписывает /etc/cron.d на архиве, собранном злоумышленником. Разберём, как модули zipfile и tarfile из стандартной библиотеки ведут себя на практике: чем отличаются, где память течёт на больших файлах и почему распаковку чужого архива нельзя делать «в лоб». Всё проверялось на CPython 3.12.
Что вообще есть в стандартной библиотеке
Оба модуля входят в поставку Python, ставить ничего не нужно. Разделение труда такое:
zipfile— формат ZIP. Один файл, внутри — записи со своим сжатием у каждой. Поддерживает случайный доступ: можно достать одну запись, не читая архив целиком.tarfile— формат TAR и его сжатые варианты (.tar.gz,.tar.bz2,.tar.xz). Сам TAR сжатия не имеет — это конкатенация файлов с заголовками, а сжимается весь поток целиком. Отсюда следствие: случайного доступа к произвольной записи нет, читается последовательно.
Практический вывод: если вам нужно достать один файл из середины большого архива — ZIP. Если важно сохранить POSIX-атрибуты (права, симлинки, владельца) и вы работаете в Unix-окружении — TAR.
Сжатие в zipfile нужно указывать явно
По умолчанию zipfile.ZipFile в режиме записи не сжимает данные — просто складывает их (ZIP_STORED). Чтобы получить сжатие, передайте compression=zipfile.ZIP_DEFLATED. Про это регулярно забывают и потом удивляются, что архив весит как исходники.
import zipfile
# Создаём zip со сжатием и уровнем компрессии
with zipfile.ZipFile(
"report.zip",
mode="w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=6, # 0..9, доступен с Python 3.7
) as zf:
zf.write("data/report.csv", arcname="report.csv")
# write_str-аналога нет, для строки — writestr
zf.writestr("meta.txt", "generated by pipeline")
Параметр arcname задаёт имя внутри архива. Без него в архив попадёт весь путь data/report.csv, включая директории, — почти никогда не то, что нужно.
Не читайте большой архив в память
Соблазн — прочитать содержимое записи целиком: data = zf.read("huge.csv"). Для файла на несколько гигабайт это OOM. Оба модуля дают файловые объекты для потокового чтения.
zipfile: open вместо read
import zipfile
with zipfile.ZipFile("dump.zip") as zf:
# open возвращает файловый объект, читаем блоками
with zf.open("huge.csv") as src, open("huge.csv", "wb") as dst:
while chunk := src.read(1024 * 1024): # по 1 МиБ
dst.write(chunk)
tarfile: extractfile отдаёт поток
import tarfile
with tarfile.open("backup.tar.gz", mode="r:gz") as tf:
member = tf.getmember("var/log/app.log")
src = tf.extractfile(member) # None, если это не обычный файл
if src is not None:
with src, open("app.log", "wb") as dst:
while chunk := src.read(1024 * 1024):
dst.write(chunk)
Обратите внимание на режим r:gz. Строка r:* просит tarfile определить сжатие автоматически — удобно, но чуть медленнее и не всегда работает для потоков без возможности перемотки. Для файла на диске r:* — разумный выбор по умолчанию.
Главная ловушка: распаковка недоверенного архива
Архив может содержать записи с именами вроде ../../etc/passwd или абсолютным путём /etc/cron.d/evil. Метод extractall() исторически позволял таким записям вылезти за пределы целевой директории — это класс атак path traversal (иногда его называют Zip Slip).
Распаковка чужого архива методом extractall() без фильтра — это удалённое произвольное создание файлов. На сервере это равнозначно RCE.
tarfile: используйте filter
В Python 3.12 у методов extract() и extractall() появился обязательный по смыслу параметр filter. Значение "data" отклоняет абсолютные пути, выход за пределы директории, символические ссылки наружу и специальные файлы устройств. Начиная с версии 3.14 значение по умолчанию станет безопасным, но пока полагаться на это нельзя — задавайте фильтр явно.
import tarfile
with tarfile.open("upload.tar.gz", mode="r:gz") as tf:
# filter="data" отсекает опасные записи; см. доку tarfile
tf.extractall(path="/srv/incoming/unpacked", filter="data")
Точное поведение фильтров "data", "tar" и "fully_trusted" описано в документации модуля tarfile — сверьтесь с разделом Extraction filters для вашей версии, поведение уточнялось между 3.12 и 3.13.
zipfile: проверяйте путь сами
У zipfile аналогичного встроенного фильтра нет. Метод extract() отбрасывает начальные слэши и ..-компоненты в именах, но полагаться на это как на защиту рискованно. Проверяйте итоговый путь явно:
import os
import zipfile
def safe_extract_zip(zip_path: str, dest_dir: str) -> None:
dest_root = os.path.realpath(dest_dir)
with zipfile.ZipFile(zip_path) as zf:
for name in zf.namelist():
target = os.path.realpath(os.path.join(dest_root, name))
# target должен лежать внутри dest_root
if os.path.commonpath([dest_root, target]) != dest_root:
raise ValueError(f"опасный путь в архиве: {name}")
zf.extractall(dest_root)
Отдельная угроза для обоих форматов — zip-бомба: маленький архив, распаковывающийся в терабайты. Перед извлечением смотрите заявленный размер (ZipInfo.file_size, TarInfo.size) и обрывайте, если сумма превышает лимит. Учтите: заявленному размеру в заголовке тоже нельзя доверять безоговорочно — на распаковке контролируйте фактически записанные байты.
zipfile против tarfile: когда что
| Критерий | zipfile (ZIP) | tarfile (TAR) |
|---|---|---|
| Достать один файл из середины | Быстро, случайный доступ | Последовательное чтение до записи |
| Права, симлинки, владелец (POSIX) | Ограниченно | Полностью |
| Сжатие | Каждая запись отдельно | Весь поток целиком (лучше жмёт похожие файлы) |
| Совместимость с Windows «из коробки» | Высокая | Ниже |
| Встроенный фильтр безопасности | Нет, проверяйте сами | Есть (filter="data") |
Что ломается на практике
- Кодировка имён. В старых ZIP имена файлов бывают в CP866 или CP1251, а не в UTF-8. Флаг UTF-8 в заголовке выставлен не всегда, и вы получаете кракозябры. Универсального лечения нет — придётся знать кодировку источника и перекодировать имя вручную.
- Пустые директории в TAR. Директория — это отдельная запись типа
DIRTYPE. Если формируете архив вручную черезaddfile, пустые каталоги нужно добавлять явно, иначе они пропадут. - Режим
r:*на неперематываемом потоке. Автоопределение сжатия требует перемотки. Для сокета или stdin указывайте конкретный режим, напримерr|gz(с вертикальной чертой — потоковый режим). - extractfile вернул None. Это не ошибка: так бывает для директорий, симлинков и прочих не-файлов. Проверяйте результат на
None, как в примере выше.
Что делать дальше
Если вы принимаете архивы от пользователей — вынесите распаковку в отдельный процесс с ограничением по CPU, памяти и месту на диске (cgroups или временная файловая система с квотой), а не только полагайтесь на проверку путей в коде. Для больших регулярных бэкапов посмотрите на потоковую передачу архива сразу в объектное хранилище без промежуточного файла — оба модуля умеют работать с файлоподобными объектами в конструкторе. И зафиксируйте в CI версию Python: поведение tarfile вокруг фильтров ещё меняется между релизами, и код, безопасный на 3.12, стоит перепроверить на 3.14.
Источники: Python docs — tarfile: Extraction filters, Python docs — zipfile
Частые вопросы
У меня Python 3.11, а не 3.12 — параметр filter в tarfile есть?
Как понять, распакуется ли архив в разумный объём, до распаковки?
extractfile вернул None, это баг?
Почему мой zip получился без сжатия?
Можно ли распаковывать архив прямо из HTTP-потока, не сохраняя на диск?
Что делать с кракозябрами в именах файлов из старого ZIP?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.