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
A los atacantes les encantan las subidas de archivos — son la vía más fácil de meter código en tu servidor. Un archivo que pase la validación puede significar ejecución remota de código, XSS almacenado o una filtración de datos. Esta receta valida las subidas en cinco capas — límites de tamaño, tipos MIME, magic bytes y estructura del contenido — antes de que nada del usuario toque tu filesystem.
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");
// file-type es ESM-only desde v19 — en CommonJS fija file-type@18
// o cárgalo dinámicamente dentro de un contexto async.
const { fileTypeFromBuffer } = await import("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 fileTypeFromBuffer(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 — lee la firma real del archivo en lugar de confiar en lo que declara el cliente. La verificación individual más fuerte.
- Escaneo de contenido / AV — esencial para cualquier entorno que maneje archivos no confiables.
Cada capa detecta lo que las demás dejan pasar — no apuestes a una sola.
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 en un buffer temporal primero.
- Usa nombres de archivo aleatorios: nunca guardes archivos con el nombre original del usuario — mapea a UUIDs internamente.
- Almacena fuera del web root: que un controlador o API sirva los archivos, nunca el filesystem directamente.
- 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.
Cuotas de subida por usuario
Rastrea el total de bytes subidos por usuario para que una sola cuenta no llene el disco. Verifica la cuota antes de las comprobaciones caras y consúmela solo después de que el archivo pase la validación — si no, las subidas rechazadas también gastan cuota:
import redis
r = redis.Redis()
def has_quota(user_id: str, file_size: int, max_quota: int = 100 * 1024 * 1024) -> bool:
key = f"upload_quota:{user_id}"
used = int(r.get(key) or 0)
return used + file_size <= max_quota
def consume_quota(user_id: str, file_size: int) -> None:
r.incrby(f"upload_quota:{user_id}", file_size)
# if not has_quota(user_id, file_size):
# raise ValueError("Cuota de subida excedida")
Elimina datos EXIF de las imágenes subidas
EXIF puede llevar coordenadas GPS, números de serie de cámara y otro PII. Re-codificar elimina la mayoría — sharp quita los metadatos por defecto (solo withMetadata() los conserva):
const sharp = require('sharp');
async function stripExif(inputBuffer) {
// EXIF/ICC se eliminan salvo que llames a .withMetadata()
return sharp(inputBuffer)
.rotate()
.png()
.toBuffer();
}
Sirve las subidas con Content-Disposition: attachment
Evita que el navegador renderice 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
- Confiar en el header Content-Type: los atacantes lo ponen a lo que quieran.
- 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: deja un ejecutable bajo el web root y el servidor puede correrlo por ti.
- Sin escaneo de virus: un MIME limpio no dice nada del payload que lleva dentro.
Ignorar las bombas de descompresión
Un ZIP pequeño puede expandirse a gigabytes al extraerlo. 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 un ZIP con protección contra bombas 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
Permitir SVGs sin sanitizar
Un SVG puede llevar tags <script> y handlers onload. O lo quitas de la lista de permitidos o lo sanitizas en servidor:
// Opción 1: no aceptar SVG
const ALLOWED = { png: 'image/png', jpg: 'image/jpeg', pdf: 'application/pdf' };
// Opción 2: sanitizar con DOMPurify (server-side)
// const DOMPurify = require('isomorphic-dompurify');
// const clean = DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true, svgFilters: true } });
No loguear los fallos de subida
Los fallos de validación 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:
filename = validate_upload(file_storage) # el validador de Solución
logger.info(f"Subida aceptada: user={user_id} file={filename}")
return filename
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
Lecturas Adicionales
- OWASP File Upload Cheat Sheet — la referencia del enfoque de validación por capas.
- Validación de Subidas en Node.js — la versión específica con Express/multer de esta receta.
- Código ejecutable del repositorio complementario — los validadores, el script de cuotas y la config de nginx como archivos descargables.
Preguntas frecuentes
¿Debería validar en el cliente o en el servidor?
En ambos. El cliente da feedback instantáneo; el servidor es la única capa que cuenta para seguridad — nunca confíes en nada que venga del cliente.
¿Cuál es la diferencia entre tipo MIME y magic bytes?
El tipo MIME viene del header Content-Type que envía el cliente — trivial de falsificar. Los magic bytes son la firma del archivo dentro del propio contenido, mucho más difíciles de falsificar.
¿Cómo evito que usuarios suban malware disfrazado de imágenes?
Combina verificación de magic bytes, re-codificación (procesa la imagen y guárdala de nuevo — eso elimina payloads embebidos) y escaneo antivirus.
¿Qué tipos de archivo no debería aceptar nunca?
Todo lo que pueda ejecutarse: .exe, .bat, .sh, .php, .py, .jar, .war, .jsp, .aspx, además de archivos de configuración del servidor como .htaccess o .env. La regla práctica: si puede ejecutarse en tu servidor, no se sube.
¿Cómo manejo subidas grandes (video, datasets)?
A partir de ~50 MB, pasa a subidas por chunks resumibles: divide en el cliente, valida cada chunk y reensambla en el servidor. tus, Uppy y Dropzone.js implementan este patrón.
¿Esta solución está lista para producción?
Sí — el enfoque por capas (extensión + MIME + magic bytes + AV + re-codificación) es lo que recomienda el OWASP File Upload Cheat Sheet. Las librerías de Variantes son las opciones estándar en cada ecosistema; añade el monitoreo y las piezas de cuota/logging de arriba y tienes un setup de producción.
¿Cuáles son las características de rendimiento?
Las verificaciones corren de más barata a más cara a propósito: extensión y MIME son lookups O(1), los magic bytes solo leen los primeros ~2 KB. La re-codificación y el escaneo AV son las etapas caras — dominan la latencia por archivo, así que ejecútalas de forma asíncrona cuando puedas. Usa streaming a almacenamiento temporal en vez de bufferar la subida completa en memoria.
¿Cómo depuro problemas de validación de subida?
Comprueba lo que cada herramienta ve realmente: file --mime-type upload.bin para el veredicto de libmagic, xxd upload.bin | head -5 para la firma (PNG 89504e47, JPEG ffd8ff, PDF 25504446). Luego revisa los límites de cada capa — fileSize de multer, spring.servlet.multipart.max-file-size de Spring, o client_max_body_size de nginx si recibes errores 413.
Recursos Relacionados
Validación de Input: Schemas en 3 Lenguajes
Valida el input de usuarios en el límite de la API con librerías de schemas. Ejemplos con Pydantic, Zod y Jakarta Validation, sanitización y errores estructurados.
RecipeSanitizar Input de Usuario
Cómo sanitizar y validar input de usuario en Python, Java y JavaScript para prevenir ataques de inyección.
RecipeValidación de Subida de Archivos en Node.js
Valida subidas de archivos en Node.js con multer para tipo, tamaño y contenido.
RecipeSubida de Archivos Drag and Drop en JavaScript con HTML5
Implementa subida de archivos drag and drop nativa con HTML5 en JavaScript: drop zones, validación, barra de progreso y previews.
RecipeOptimización de Imágenes
Cómo redimensionar, comprimir y optimizar imágenes para rendimiento web.
GuideChecklist de Seguridad API: Autenticación a Encriptación
Checklist de seguridad esencial para APIs: autenticación, autorización, validación de entrada, rate limiting, encriptación, logging y endurecimiento de despliegue.