Extraer Archivos Zip de Forma Segura con Python
Cómo extraer y validar archivos zip de forma segura usando zipfile y shutil en Python.
Visión General
Llamar a zf.extractall() sobre un archivo subido por un usuario es de esas cosas que funcionan perfecto en desarrollo y luego te traen un CVE. Un zip malicioso puede llevar entradas de path traversal (../../etc/passwd) o zip bombs que se expanden hasta llenar el disco. El módulo zipfile te da todo lo necesario para revisar un archivo antes; el truco está en hacerlo antes de escribir un solo byte.
Cuándo Usar
- Para extraer zips que los usuarios suben a tu app
- Cuando procesas archivos de fuentes que no controlas
- Para validar el contenido primero (cantidad de archivos, tamaño total descomprimido)
- Para sacar unos pocos archivos de un archivo sin descomprimirlo entero
Solución
Extracción básica
import zipfile
with zipfile.ZipFile("archive.zip", "r") as zf:
zf.extractall("output_dir")
Extracción segura con protección path traversal
import zipfile
import os
def safe_extract(zip_path, extract_to):
with zipfile.ZipFile(zip_path, "r") as zf:
for member in zf.namelist():
# Resolver el path destino
target = os.path.realpath(os.path.join(extract_to, member))
# Asegurar que el destino está dentro del directorio de extracción
if not target.startswith(os.path.realpath(extract_to) + os.sep):
raise ValueError(f"Path traversal detectado: {member}")
# Solo extraer después de que la validación pase
zf.extractall(extract_to)
safe_extract("archive.zip", "output_dir")
Validar antes de extraer
import zipfile
def validate_zip(zip_path, max_files=1000, max_total_size_mb=500):
with zipfile.ZipFile(zip_path, "r") as zf:
files = zf.namelist()
if len(files) > max_files:
raise ValueError(f"Demasiados archivos: {len(files)} (max {max_files})")
total_size = sum(info.file_size for info in zf.infolist())
if total_size > max_total_size_mb * 1024 * 1024:
raise ValueError(f"Archivo demasiado grande: {total_size / 1024 / 1024:.1f}MB")
# Revisar entradas sospechosas
for member in files:
if member.startswith("/") or ".." in member:
raise ValueError(f"Path inseguro en archivo: {member}")
return True
if validate_zip("archive.zip"):
with zipfile.ZipFile("archive.zip", "r") as zf:
zf.extractall("output_dir")
Extraer solo archivos específicos
import zipfile
with zipfile.ZipFile("archive.zip", "r") as zf:
# Listar todos los archivos
for name in zf.namelist():
print(name)
# Extraer solo archivos .csv
csv_files = [f for f in zf.namelist() if f.endswith(".csv")]
for f in csv_files:
zf.extract(f, "csv_output/")
Extraer a memoria sin escribir al disco
import zipfile
with zipfile.ZipFile("archive.zip", "r") as zf:
with zf.open("data.json") as f:
content = f.read()
# Procesar contenido directamente sin escribir al disco
print(content[:200])
Explicación
El detalle clave: zipfile puede leer los metadatos del archivo (nombres, tamaños, compresión) sin extraer nada, así que puedes inspeccionar un archivo antes de que toque el disco.
El path traversal es el truco clásico, y el que más dolores de cabeza da. Una entrada llamada ../../etc/passwd le dice a extractall() que escriba fuera del directorio destino, y obedece sin protestar. La versión segura resuelve el path de cada entrada y rechaza lo que se salga.
Los zip bombs son el otro ataque: un archivo de 42KB en disco que al descomprimirse llega a petabytes en memoria. Sumar file_size de todas las entradas antes de extraer los detecta.
Juntando todo, el pipeline de validación queda así:
Variantes
| Enfoque | Seguridad | Usar Cuando |
|---|---|---|
| extractall() | Ninguna | Solo archivos confiables |
| Extracción segura con chequeo de path | Alta | Uploads de usuarios |
| Validar + extraer | Máxima | Fuentes no confiables |
| Extraer a memoria | Alta | Procesamiento sin I/O de disco |
Pautas
- Nunca llames
extractall()en archivos no confiables sin validación. - Revisa el tamaño total descomprimido antes de extraer para evitar zip bombs.
- Resolver con
os.path.realpath()tiene un extra: también detecta el traversal a través de symlinks. - Cuando no necesitas el archivo en disco,
zf.open()lo lee directamente a memoria. - Pon también un tope a la cantidad de archivos: los archivos legítimos casi nunca traen 10,000 entradas.
- Usa
Path.resolve()en vez deos.path.realpath()para código moderno.Path.resolve()maneja symlinks y normaliza paths en una llamada, y funciona de forma consistente entre plataformas:
from pathlib import Path
def is_safe_path(extract_dir: Path, member_name: str) -> bool:
"""Verifica si un path de miembro zip es seguro (sin traversal)."""
target = (extract_dir / member_name).resolve()
try:
target.relative_to(extract_dir.resolve())
return True
except ValueError:
return False
- Pon en cuarentena los archivos sospechosos en vez de eliminarlos. Mueve los zips sospechosos a un directorio de cuarentena para analizarlos después. Así te quedas con la evidencia para la respuesta a incidentes:
import logging
import shutil
from pathlib import Path
logger = logging.getLogger(__name__)
QUARANTINE_DIR = Path("/app/quarantine")
def quarantine_zip(zip_path: str, reason: str) -> str:
"""Mueve un zip sospechoso a cuarentena. Retorna el path de cuarentena."""
QUARANTINE_DIR.mkdir(parents=True, exist_ok=True)
dest = QUARANTINE_DIR / Path(zip_path).name
shutil.move(zip_path, str(dest))
logger.warning(f"En cuarentena {zip_path}: {reason}")
return str(dest)
- Registra metadatos de extracción para auditoría. Anota quién extrajo qué, cuándo, y los hashes de los archivos extraídos si tu compliance lo requiere (SOC 2, PCI-DSS):
import json
from datetime import datetime, timezone
def log_extraction_audit(zip_path: str, result: dict, user_id: str) -> None:
"""Escribe el log de auditoría de extracción como JSON."""
audit_entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"user_id": user_id,
"zip_path": zip_path,
"extracted_count": result["extracted_count"],
"total_uncompressed": result["total_uncompressed"],
"file_hashes": result["hashes"],
}
with open("/var/log/zip_extraction_audit.jsonl", "a") as f:
f.write(json.dumps(audit_entry) + "\n")
Errores Comunes
- Llamar
extractall()directamente en uploads de usuarios. Esta es la que causa la mayoría de las vulnerabilidades de extracción de zips en la vida real. - No revisar
file_size(descomprimido). Un zip de 1MB puede esconder entradas que se inflan hasta los GBs. - Confiar solo en chequeos de
member.startswith(".."). Los symlinks y los paths absolutos pueden saltarse chequeos simples de strings. - Olvidar manejar archivos zip protegidos con contraseña.
zf.extractall(pwd=b"secret")lanzaRuntimeErrorcon contraseñas incorrectas. - No cerrar el contexto de ZipFile. Usa
withpara asegurar que el handle del archivo se libere. - No manejar filenames no-UTF8 en zips. Los zips creados en Windows pueden usar CP437 o GBK para los nombres de archivo.
zipfilede Python asume nombres en UTF-8, lo que puede lanzarUnicodeDecodeErroro producir nombres ilegibles:
import zipfile
# Mal: el encoding por defecto puede fallar en zips no-UTF8
# zf = zipfile.ZipFile("chinese_archive.zip", "r")
# names = zf.namelist() # Puede lanzar error o devolver nombres ilegibles
# Bien: manejar los errores de encoding
def safe_namelist(zf: zipfile.ZipFile) -> list[str]:
"""Obtener nombres de archivo zip con fallback de encoding."""
names = []
for info in zf.infolist():
try:
if info.flag_bits & 0x800:
names.append(info.filename)
else:
raw = info.filename.encode("cp437")
names.append(raw.decode("utf-8", errors="replace"))
except Exception:
names.append(info.filename.encode("ascii", errors="replace").decode("ascii"))
return names
- Extraer zips de fuentes no confiables sin timeout. Un zip malicioso puede dejar la extracción colgada indefinidamente. El ejemplo de abajo usa
signal.alarm, que solo existe en Unix. Si necesitas lo mismo en Windows, envuelve la extracción en un subproceso con timeout:
import signal
import zipfile
class TimeoutError(Exception):
pass
def _timeout_handler(signum, frame):
raise TimeoutError("Extracción zip agotó el tiempo")
def extract_with_timeout(zip_path: str, dest: str, timeout_sec: int = 60) -> int:
"""Extrae zip con timeout para evitar cuelgues. Solo Unix."""
signal.signal(signal.SIGALRM, _timeout_handler)
signal.alarm(timeout_sec)
try:
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(dest)
return len(zf.namelist())
finally:
signal.alarm(0)
# extract_with_timeout("upload.zip", "/app/output", timeout_sec=30)
- No verificar filenames duplicados entre entradas del zip. Algunos zips maliciosos incluyen el mismo filename varias veces. La última extracción gana, lo que puede sobrescribir un archivo seguro con uno malicioso:
import zipfile
from collections import Counter
def check_duplicates(zip_path: str) -> list[str]:
"""Encuentra filenames duplicados en un archivo zip."""
with zipfile.ZipFile(zip_path, "r") as zf:
names = [m for m in zf.namelist() if not m.endswith("/")]
counts = Counter(names)
return [name for name, count in counts.items() if count > 1]
# dupes = check_duplicates("archive.zip")
# if dupes:
# raise ValueError(f"Entradas duplicadas encontradas: {dupes}") Preguntas frecuentes
¿Cómo extraigo un zip protegido con contraseña?
Pasa la contraseña como bytes: zf.extractall("output", pwd=b"mypassword"). Los zips con encriptación AES necesitan pyzipper; el zipfile de la stdlib no los abre.
¿Cómo detecto un zip bomb?
Revisa el ratio de compresión. Cuando el tamaño descomprimido supera unas 100 veces el comprimido, algo definitivamente no cuadra. Hay una segunda línea de defensa más simple: poner un tope al tamaño total descomprimido en algo razonable, digamos 500MB.
¿Puedo extraer archivos .tar.gz con zipfile?
No. Para archivos tar usa el módulo tarfile. La API es casi idéntica también: tarfile.open("file.tar.gz", "r:gz"). Para archivos sueltos comprimidos con gzip en vez de archivos, mira Comprimir y Descomprimir Archivos o la receta de compresión gzip.
¿Cómo creo un archivo zip en Python?
import zipfile
with zipfile.ZipFile("output.zip", "w", zipfile.ZIP_DEFLATED) as zf:
zf.write("file1.txt")
zf.write("file2.txt")
¿Cómo manejo archivos zip con encriptación AES?
zipfile de la stdlib de Python solo soporta la encriptación legacy ZipCrypto. Para zips con AES-256, usa pyzipper:
from pyzipper import AESZipFile
with AESZipFile("encrypted.zip", "r", compression=pyzipper.ZIP_LZMA, encryption=pyzipper.WZ_AES) as zf:
zf.setpassword(b"mypassword")
zf.extractall("output_dir")
¿Cómo extraigo solo archivos modificados después de cierta fecha?
Usa ZipInfo.date_time para filtrar entradas por fecha de modificación:
import zipfile
from datetime import datetime
def extract_after_date(zip_path: str, dest: str, after: datetime) -> list[str]:
"""Extrae solo archivos modificados después de la fecha dada."""
extracted = []
with zipfile.ZipFile(zip_path, "r") as zf:
for info in zf.infolist():
if info.is_dir():
continue
file_date = datetime(*info.date_time)
if file_date > after:
zf.extract(info, dest)
extracted.append(info.filename)
return extracted
# recent = extract_after_date("archive.zip", "/app/output", datetime(2025, 1, 1))
¿Este enfoque es suficientemente seguro para uploads en producción?
El patrón de validación de aquí (resolver cada path, rechazar lo que salga del destino, limitar cantidad de archivos y tamaño total) cubre los ataques estándar de extracción de zips descritos en OWASP y CWE-22. Lo que no cubre es malware dentro del archivo: si aceptas uploads del público, analiza los archivos extraídos con tu pipeline de antivirus habitual, y pon en cuarentena lo que el validador rechace en vez de eliminarlo. Para uploads no confiables, combina esto con los chequeos de validación de uploads de archivos.
¿Cómo depuro problemas de extracción de zips?
Empieza por el mensaje de error. "Bad zip file" suele significar que el archivo no es un zip en absoluto: compruébalo con python -c "import zipfile; zipfile.ZipFile('file.zip').testzip()". Si el chequeo de paths rechaza un archivo que parece inofensivo, imprime los dos paths resueltos (os.path.realpath(extract_to) y el destino resuelto de la entrada) para ver por qué se salió.
Los nombres de archivo ilegibles casi siempre vienen de un zip que no es UTF-8: info.flag_bits & 0x800 te dice si la entrada declara UTF-8. Un RuntimeError sobre encriptación significa que necesitas pwd=b"...", o pyzipper si es AES. Una extracción que se cuelga pide el patrón de timeout de arriba, y "disk full" es justo lo que el chequeo de tamaño de validate_zip evita: ejecútalo antes de extraer, no después. Los archivos corruptos a veces abren con allowZip64=True, y jar xf file.zip tolera daños que Python no acepta. Los errores de permisos son el caso más simple: os.access(extract_to, os.W_OK) te dice si el destino es escribible.
Recursos Relacionados
Comprimir y Descomprimir Archivos
Cómo manejar archivos ZIP, GZIP y TAR programáticamente.
RecipeValidación de Subida de Archivos
Cómo manejar subidas de archivos de forma segura con validación de tamaño, tipo y contenido.
RecipeComprimir y Descomprimir Archivos con Gzip y Brotli
Cómo reducir tamaños de archivos para APIs, assets estáticos y logs usando Gzip, Brotli y zlib con compresión streaming, negociación de contenido y lo que funciona.
RecipeCómo Copiar y Mover Archivos con Python, JS, Java y Bash
Aprende a copiar y mover archivos multiplataforma con Python, JavaScript, Java y Bash. Incluye movimientos atómicos, checksums, symlinks y patrones batch.
RecipeGenerar Archivos Temporales
Cómo crear archivos y directorios temporales de forma segura con limpieza automática en Python, Node.js, Java y Bash.