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
Extraer archivos zip es una tarea rutinaria, pero hacerla de forma segura requiere validación. Archivos maliciosos pueden contener entradas de path traversal (../../etc/passwd) o zip bombs que agotan el disco. El módulo zipfile de Python te da las herramientas para extraer de forma segura si verificas las entradas antes de escribir.
Cuándo Usar
- Necesitas extraer archivos zip subidos por usuarios
- Estás procesando archivos de fuentes no confiables
- Quieres validar el contenido del zip antes de extraer (cantidad de archivos, tamaño total)
- Necesitas extraer archivos específicos sin descomprimir todo
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 módulo zipfile lee metadatos del archivo (nombres, tamaños, compresión) sin extraer. Usa esto para validar antes de escribir nada al disco.
Ataques de path traversal funcionan incluyendo entradas como ../../etc/passwd en el archivo. Si llamas extractall() sin validación, Python escribe archivos a esos paths. La función de extracción segura verifica que cada path resuelto stays dentro del directorio destino.
Zip bombs son archivos que se descomprimen a tamaños enormes (e.g., 42KB que se expande a 4.5PB). Revisa file_size de cada entrada y súmalos antes de extraer.
Variantes
| Enfoque | Seguridad | Usar Cuando |
|---|---|---|
| extractall() | Ninguna | Solo archivos confiables |
| Extracción segura con path check | 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.
- Resuelve paths con
os.path.realpath()para detectar traversal basado en symlinks. - Usa
zf.open()para leer archivos a memoria cuando no los necesitas en disco. - Define un límite de cantidad de archivos. Archivos legítimos rara vez contienen 10,000 archivos.
Errores Comunes
- Llamar
extractall()directamente en uploads de usuarios. Esta es la vulnerabilidad de extracción zip más común. - No revisar
file_size(descomprimido). Un zip de 1MB puede contener entradas que se expanden a GBs. - Confiar solo en checks de
member.startswith(".."). Symlinks y paths absolutos pueden bypassar checks simples de strings. - Olvidar manejar archivos zip protegidos con contraseña.
zf.extractall(pwd=b"secret")lanzaRuntimeErrorcon passwords incorrectos. - No cerrar el contexto de ZipFile. Usa
withpara asegurar que el file handle se libere.
Mejores Prácticas Adicionales
- For a deeper guide, see Compress and Decompress Files.
- Usa
Path.resolve()en vez deos.path.realpath()para código moderno.Path.resolve()maneja symlinks y normaliza paths en una llamada, y funciona consistentemente 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()
return str(target).startswith(str(extract_dir.resolve()) + "/")
- Cuarentena archivos sospechosos en vez de eliminarlos. Mueve zips sospechosos a un directorio de cuarentena para análisis posterior. Esto preserva evidencia para respuesta a incidentes:
import shutil
from pathlib import Path
QUARANTINE_DIR = Path("/app/quarantine")
def quarantine_zip(zip_path: str, reason: str) -> str:
"""Mueve un zip sospechoso a cuarentena. Retorna 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)
- Loguea metadata de extracción para auditoría. Registra quién extrajo qué, cuándo, y los hashes de los archivos extraídos. Esto es esencial para compliance (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 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 Adicionales
- No manejar filenames no-UTF8 en zips. Zips creados en Windows pueden usar CP437 o GBK para nombres de archivo.
zipfilede Python usa UTF-8 por defecto, lo que puede causarUnicodeDecodeErroro nombres garabateados:
import zipfile
# Mal: encoding default puede fallar en zips no-UTF8
# zf = zipfile.ZipFile("chinese_archive.zip", "r")
# names = zf.namelist() # Puede lanzar error o retornar nombres garabateados
# Bien: manejar errores de encoding graceful
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 causar que la extracción cuelgue indefinidamente. Usa
signal.alarmo ejecuta la extracción en un proceso separado con timeout:
import signal
import zipfile
class TimeoutError(Exception):
pass
def _timeout_handler(signum, frame):
raise TimeoutError("Extracción zip timed out")
def extract_with_timeout(zip_path: str, dest: str, timeout_sec: int = 60) -> int:
"""Extrae zip con timeout para prevenir cuelgues."""
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 zip. Algunos zips maliciosos incluyen el mismo filename múltiples 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 el password como bytes: zf.extractall("output", pwd=b"mypassword"). Para zips con encriptación AES, instala pyzipper en vez de usar zipfile de stdlib.
¿Cómo detecto un zip bomb?
Revisa el ratio de compresión. Si el tamaño descomprimido es más de 100x el tamaño comprimido, trátalo como sospechoso. También define un límite hard en el tamaño total descomprimido (e.g., 500MB).
¿Puedo extraer archivos .tar.gz con zipfile?
No. Usa el módulo tarfile para archivos tar. Tiene una API similar: tarfile.open("file.tar.gz", "r:gz").
¿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")
Recursos Relacionados
Comprimir y Descomprimir Archivos
Cómo manejar archivos ZIP, GZIP y TAR programáticamente.
RecipeConfigurar Reglas de Firewall con iptables
Configura reglas de firewall básicas usando iptables en Bash para filtrar tráfico, bloquear puertos y proteger servidores Linux.
RecipeGestión de Claves SSH
Genera, rota y distribuye claves SSH de forma segura con scripts de Bash para equipos y acceso a servidores.
RecipeCopiar y Mover Archivos
Cómo copiar y mover archivos de forma segura y eficiente entre plataformas.
RecipeGenerar Archivos Temporales
Cómo crear archivos y directorios temporales de forma segura con limpieza automática en Python, Node.js, Java y Bash.