Validación de Subida de Archivos
Cómo manejar subidas de archivos de forma segura con validación de tamaño, tipo y contenido.
Visión General
Las subidas de archivos son uno de los vectores de ataque más comunes en aplicaciones web. Subidas sin validación pueden provocar ejecución remota de código, cross-site scripting y filtraciones de datos. A continuacion se muestra como cómo validar subidas de archivos verificando límites de tamaño, tipos MIME, magic bytes y estructura del contenido antes de aceptar cualquier archivo de un usuario.
Cuándo Usar
Usa este recurso cuando:
- Construyas una app web que acepte imágenes, documentos o media generados por usuarios. Consulta Optimización de Imágenes para procesamiento post-subida.
- Implementes un CMS, foro o SaaS con soporte de adjuntos. Consulta Exportar CSV Excel para capacidades de exportación de datos.
- Necesites cumplir con estándares de seguridad (PCI-DSS, SOC 2). Consulta Gestión de Secretos para almacenamiento seguro de credenciales.
- Proceses archivos de fuentes no confiables (formularios públicos, APIs). Consulta Input Validation para manejo de entrada no confiable.
Solución
Python
import os
import magic
from werkzeug.utils import secure_filename
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "pdf"}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
def validate_upload(file_storage):
# 1. Verificar extensión del archivo
filename = secure_filename(file_storage.filename)
ext = filename.rsplit(".", 1)[1].lower() if "." in filename else ""
if ext not in ALLOWED_EXTENSIONS:
raise ValueError(f"Extensión no permitida: {ext}")
# 2. Verificar tamaño del archivo
file_storage.seek(0, os.SEEK_END)
size = file_storage.tell()
file_storage.seek(0)
if size > MAX_FILE_SIZE:
raise ValueError(f"Archivo demasiado grande: {size} bytes")
# 3. Verificar magic bytes (libmagic)
mime = magic.from_buffer(file_storage.read(2048), mime=True)
file_storage.seek(0)
expected_mimes = {
"png": "image/png", "jpg": "image/jpeg",
"jpeg": "image/jpeg", "gif": "image/gif", "pdf": "application/pdf"
}
if mime != expected_mimes.get(ext):
raise ValueError(f"MIME no coincide: recibido {mime}, esperado {expected_mimes.get(ext)}")
return filename
JavaScript (Node.js)
const path = require("path");
const multer = require("multer");
const fileType = require("file-type");
const fs = require("fs");
const ALLOWED = { png: "image/png", jpg: "image/jpeg", pdf: "application/pdf" };
const MAX_SIZE = 5 * 1024 * 1024;
const upload = multer({
limits: { fileSize: MAX_SIZE },
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase().replace(".", "");
if (!ALLOWED[ext]) return cb(new Error("Extensión no permitida"));
cb(null, true);
},
});
async function validateBuffer(buffer, ext) {
const type = await fileType.fromBuffer(buffer);
if (!type || type.mime !== ALLOWED[ext]) {
throw new Error(`MIME no coincide: ${type?.mime}`);
}
return true;
}
Java (Spring Boot)
import org.springframework.web.multipart.MultipartFile;
import java.util.Set;
public class UploadValidator {
private static final Set<String> ALLOWED = Set.of("image/png", "image/jpeg", "application/pdf");
private static final long MAX_SIZE = 5L * 1024 * 1024;
public static void validate(MultipartFile file) {
if (file.getSize() > MAX_SIZE) {
throw new IllegalArgumentException("Archivo excede 5 MB");
}
String contentType = file.getContentType();
if (!ALLOWED.contains(contentType)) {
throw new IllegalArgumentException("Tipo MIME no permitido: " + contentType);
}
// Adicional: verificar magic bytes con Apache Tika o similar
}
}
Explicación
La validación debe ocurrir en capas:
- Cliente — mejora la UX pero es trivial de evitar.
- Verificación de extensión en servidor — rápida pero fácil de falsificar.
- Verificación de tipo MIME en servidor — mejor, pero aún depende de headers HTTP.
- Magic bytes (firma de archivo) — lee el contenido real del archivo para determinar su tipo. La verificación individual más confiable.
- Escaneo de contenido / AV — esencial para cualquier entorno que maneje archivos no confiables.
Cada capa detecta amenazas diferentes. Nunca confíes en una sola verificación.
Variantes
| Tecnología | Librería de Validación | Notas |
|---|---|---|
| Python | python-magic | Lee base de datos libmagic; muy preciso |
| Node.js | file-type | Puro JS, rápido, sin dependencias nativas |
| Java | Apache Tika | Pesado pero maneja 1000+ formatos |
| Go | mimetype | Rápido, puro Go, lecturas sin allocación |
| Ruby | Marcel | Default de Rails, usa extensión y magic |
Lo que funciona
- Valida antes de guardar en disco: Verifica todo en memoria o un buffer temporal primero.
- Usa nombres de archivo aleatorios: Nunca guardes archivos con los nombres originales del usuario. Mapea a UUIDs internamente.
- Almacena fuera del web root: Sirve archivos vía controlador/API, no acceso directo al filesystem.
- Escanea con AV: Integra ClamAV o un scanner en la nube para subidas no confiables.
- Limita la tasa de subidas: Previene abuso y agotamiento de disco.
Errores Comunes
- Confiar en el header Content-Type: Los atacantes pueden establecerlo a cualquier cosa.
- Depender solo de la extensión: Un
.jpgpuede contener código PHP. - Sin límite de tamaño: Una sola subida puede llenar tu disco.
- Guardar en directorios públicos: Si el archivo es ejecutable, puede ser servido y ejecutado.
- Sin escaneo de virus: Archivos maliciosos pueden pasar verificaciones de tipo pero aún dañar usuarios.
Mejores Prácticas Adicionales
- Establece cuotas de subida por usuario. Rastrea el total de bytes subidos por usuario para prevenir agotamiento de disco desde una sola cuenta. Almacena cuotas en Redis o tu base de datos:
import redis
r = redis.Redis()
def check_quota(user_id: str, file_size: int, max_quota: int = 100 * 1024 * 1024) -> bool:
"""Verifica si el usuario tiene cuota suficiente para esta subida."""
key = f"upload_quota:{user_id}"
used = int(r.get(key) or 0)
if used + file_size > max_quota:
return False
r.incrby(key, file_size)
return True
# if not check_quota(user_id, file_size):
# raise ValueError("Cuota de subida excedida")
- Elimina datos EXIF de imágenes subidas. EXIF puede contener coordenadas GPS, números de serie de cámara y otro PII. La re-codificación con
sharpoPILelimina la mayoría de metadatos:
const sharp = require('sharp');
async function stripExif(inputBuffer) {
return sharp(inputBuffer)
.rotate()
.removeExif()
.png()
.toBuffer();
}
// const cleanBuffer = await stripExif(req.file.buffer);
- Usa Content-Disposition: attachment para servir subidas de usuarios. Previene que los navegadores rendericen archivos subidos inline, lo que podría ejecutar scripts en el contexto de tu dominio:
location /uploads/ {
add_header Content-Disposition "attachment";
add_header X-Content-Type-Options "nosniff";
add_header Content-Security-Policy "default-src 'none'";
}
Errores Comunes Adicionales
- No verificar bombas de descompresión. Un ZIP pequeño subido puede expandirse a gigabytes al extraer. Limita el tamaño descomprimido durante la extracción:
import zipfile
MAX_DECOMPRESSED_SIZE = 100 * 1024 * 1024 # 100 MB
def safe_extract_zip(zip_path: str, dest_dir: str) -> int:
"""Extrae ZIP con protección contra bomba de descompresión."""
total_size = 0
count = 0
with zipfile.ZipFile(zip_path, 'r') as zf:
for info in zf.infolist():
total_size += info.file_size
if total_size > MAX_DECOMPRESSED_SIZE:
raise ValueError(f"Bomba de descompresión: {total_size} bytes")
zf.extract(info, dest_dir)
count += 1
return count
# safe_extract_zip('upload.zip', '/app/extracted/')
- Permitir subidas SVG sin sanitización. Los archivos SVG pueden contener tags
<script>y handlersonload. Sanitiza SVGs o prohíbelos completamente:
// SVG puede contener XSS: <svg onload="alert(document.cookie)">
// Opción 1: Prohibir SVG completamente
const ALLOWED = { png: 'image/png', jpg: 'image/jpeg', pdf: 'application/pdf' };
// Opción 2: Sanitizar SVG con DOMPurify (server-side)
// const DOMPurify = require('isomorphic-dompurify');
// const clean = DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true, svgFilters: true } });
- No loguear fallos de subida. Los fallos de validación de subida son eventos de seguridad. Loguéalos con contexto para auditoría y respuesta a incidentes:
import logging
logger = logging.getLogger('upload_security')
def validate_upload_with_logging(file_storage, user_id: str):
try:
result = validate_upload_secure(file_storage)
logger.info(f"Subida aceptada: user={user_id} file={result['original_name']} size={result['size']}")
return result
except ValueError as e:
logger.warning(f"Subida rechazada: user={user_id} reason={e} filename={file_storage.filename}")
raise
except Exception as e:
logger.error(f"Error de subida: user={user_id} error={e} filename={file_storage.filename}", exc_info=True)
raise Preguntas frecuentes
Debería validar en el cliente o en el servidor?
En ambos. La validación en cliente mejora la UX con feedback instantáneo. La validación en servidor es obligatoria para seguridad — nunca confíes en nada del cliente.
Cuál es la diferencia entre tipo MIME y magic bytes?
El tipo MIME es declarado por el cliente en el header HTTP Content-Type. Los magic bytes son la firma real del archivo leída de los primeros bytes de su contenido. Los magic bytes son mucho más difíciles de falsificar.
Cómo evito que usuarios suban malware disfrazado de imágenes?
Usa una combinación de magic bytes, re-codificación (procesa la imagen y guárdala de nuevo) y escaneo antivirus. La re-codificación elimina scripts embebidos de archivos de imagen.
Recursos Relacionados
Validación de Input
Cómo validar input de usuarios de forma segura usando schemas, type checking y sanitización en Python, JavaScript y Java.
RecipeAutenticación JWT
Cómo generar, validar y refrescar JSON Web Tokens para autenticación de APIs sin estado.
RecipeCómo hashear contraseñas (Python, JavaScript, Java)
Aprendé a hashear y verificar contraseñas con bcrypt, Argon2 y PBKDF2. Ejemplos prácticos en Python, JavaScript y Java, más pasos de migración y trade-offs de parámetros.
RecipeExpresiones Regulares
Cómo usar expresiones regulares para matching de patrones, validación y extracción de texto en Python, JavaScript y Java.
RecipeLeer y Escribir Archivos
Cómo leer y escribir archivos de forma segura en varios lenguajes de programación.
RecipeComprimir y Descomprimir Archivos
Cómo manejar archivos ZIP, GZIP y TAR programáticamente.