Toda aplicación que permite a los usuarios subir algo — una foto de perfil, un currículum, una factura, un archivo adjunto — abre una puerta a su servidor. La mayoría de las veces esa puerta se usa exactamente como usted pretendía. Pero los endpoints de subida de archivos son de forma constante una de las superficies más explotadas en las aplicaciones web, porque una sola comprobación ausente puede convertir un botón aparentemente inofensivo de “Subir” en ejecución remota de código, XSS almacenado o una vía completamente abierta hacia los archivos privados de cualquier otro usuario.
El error que cometen la mayoría de los desarrolladores es tratar la seguridad de subida de archivos como una única comprobación: ¿el nombre del archivo termina en .jpg? Esa verificación tarda segundos en ser burlada. La verdadera seguridad de subida es una canalización: una secuencia de validaciones independientes, cada una cerrando una clase distinta de ataque, sin un único punto de fallo.
En esta guía, aprenderá:
- Por qué comprobar solo la extensión no es seguridad, y qué comprobar en su lugar
- Cómo validar archivos a nivel de bytes para que los atacantes no puedan disfrazar ejecutables como imágenes
- Cómo construir una canalización completa de subida: desde la solicitud hasta la cuarentena y el almacenamiento aprobado
- Cómo manejar de forma segura imágenes, archivos ZIP y documentos de Office en particular
- Código Python funcional para las validaciones que realmente importan en producción
- Los errores que convierten silenciosamente sistemas de subida “seguros” en puertas abiertas
Entremos en materia.
Tabla de contenidos
- Comprender la seguridad en la subida de archivos: lo básico
- La arquitectura completa de defensa: cómo encaja todo
- Capas principales de seguridad explicadas
- El recorrido de la subida: de la solicitud al almacenamiento aprobado
- Manejo de tipos de archivo especiales
- Escalado y desafíos en producción
- Detalles de implementación específicos por lenguaje y framework
- Construir su canalización de seguridad de subida: ejemplos de código
- Errores comunes y cómo evitarlos
- Mejores prácticas para producción
Comprender la seguridad en la subida de archivos: lo básico
Qué cubre realmente la seguridad en la subida de archivos
La seguridad en la subida de archivos es el conjunto de controles que se interponen entre “un usuario envió bytes a mi servidor” y “esos bytes se almacenan de forma segura y se devuelven de forma segura”. No es un solo control: es una canalización por capas que debe responder varias preguntas independientes: ¿es este el tipo de archivo que realmente acepto? ¿Es lo que afirma ser? ¿Tiene un tamaño razonable? ¿Contiene código malicioso? ¿Puede su nombre usarse para escapar del directorio en el que pretendía almacenarlo? Y una vez almacenado, ¿solo el usuario correcto puede recuperarlo?
Si se equivoca en cualquiera de esas respuestas, las demás no importan. Un escáner de malware perfecto es inútil si un atacante puede subir un archivo que sobrescribe /etc/passwd mediante un nombre manipulado. Un sanitizador de nombres de archivo perfecto no ayuda si usted ejecuta alegremente archivos .php colocados en su directorio de subidas.
Por qué las subidas son una superficie de ataque de alto valor
Las subidas resultan atractivas para los atacantes por una razón simple: son uno de los pocos lugares donde un usuario puede enviarle datos binarios arbitrarios y hacer que su servidor los almacene, los procese y, a menudo, los devuelva a otros usuarios. Compárelo con un campo normal de formulario, donde la entrada es texto y se valida contra un esquema estrecho. Un campo de subida de archivos, en cambio, suele aceptar megabytes de contenido binario opaco que su validación debe inspeccionar activamente en lugar de analizar pasivamente.
Considere lo que realmente está en juego:
- Ejecución remota de código — si un archivo subido puede ser ejecutado por el servidor (un archivo
.phpo.jspcolocado en un directorio accesible por la web), el atacante ahora ejecuta código en su infraestructura. - Cross-site scripting almacenado — un archivo SVG o HTML subido y servido luego a otros usuarios puede llevar un script incrustado que se ejecuta en la sesión de su navegador.
- Path traversal y sobrescritura de archivos — un nombre de archivo manipulado como
../../config/settings.pypuede permitir a un atacante escribir fuera del directorio de subida previsto. - Denegación de servicio — archivos sobredimensionados, extracción ZIP sin límites o una avalancha de solicitudes de subida pueden agotar disco, memoria o CPU.
- Exposición de datos — un control de acceso débil sobre los archivos almacenados permite que el Usuario A lea o sobrescriba archivos que pertenecen al Usuario B.
Nada de esto requiere un exploit sofisticado. La mayoría no requieren más que un archivo renombrado y una comprobación del lado del servidor ausente.
La arquitectura completa de defensa: cómo encaja todo
Los sistemas de subida en producción no validan un archivo una sola vez y lo dan por hecho: lo hacen pasar por una canalización, donde un archivo solo avanza a la siguiente etapa si sobrevive a la anterior. La forma de esa canalización es la siguiente:
Upload request
↓
Authentication & authorization check
↓
Rate limit check
↓
Temporary storage (not web-accessible)
↓
Extension + MIME + magic byte validation
↓
Size and count enforcement
↓
Filename sanitization & randomization
↓
Content-specific processing (re-encode images, inspect archives)
↓
Malware scan
↓
Approved storage (object storage, outside web root)
↓
Available to users (via signed URL / authenticated download)
La decisión arquitectónica clave aquí es que nunca se confía en un archivo, y nunca se hace accesible públicamente, hasta que haya superado cada etapa. Si su escáner está caído, el archivo permanece en cuarentena; no se sirve de todos modos “solo por esta vez”. Este es el mismo principio que hace fiables los sistemas basados en colas en otras partes de su stack: cada etapa es responsable de una sola cosa de forma independiente, y un fallo en cualquier etapa bloquea el progreso en lugar de dejar pasar silenciosamente el archivo.
Capas principales de seguridad explicadas
Recorramos cada capa de la canalización y entendamos exactamente contra qué protege, porque cada una existe para cerrar una brecha específica y explotable.
Permita por lista blanca tipos de archivo, nunca los bloquee por lista negra
Bloquear extensiones conocidas como peligrosas (.exe, .php, .sh) es una batalla perdida: está intentando enumerar cada extensión peligrosa en cada configuración de servidor, y omitirá algunas (.phtml, .php5, .asp, .jsp, .cgi, .htaccess). El enfoque correcto es el inverso: definir el pequeño conjunto de extensiones que su aplicación realmente necesita y rechazar todo lo demás por defecto.
Allow: .jpg .jpeg .png .pdf .docx
Reject: everything not explicitly listed
Una allowlist es una lista blanca de seguridad, no una blocklist de peligro conocido. Falla de forma cerrada en lugar de abierta.
Consejo para producción: Mantenga la allowlist tan pequeña como realmente requiera el producto. Cada extensión que agrega es una nueva clase de archivo que su lógica de validación, almacenamiento y renderizado debe manejar de forma segura. Un campo para subir currículums no necesita aceptar
.zip.
Valide el tipo MIME del lado del servidor
La cabecera Content-Type que su cliente envía con una subida es solo una cadena que el navegador o cliente decidió enviar; no se verifica contra el contenido real del archivo, y un atacante puede establecerla como quiera. Confiar en ella equivale a confiar en una identificación autodeclarada.
La validación real de MIME ocurre del lado del servidor, inspeccionando el contenido real del archivo y comparando el tipo detectado con lo que espera para ese campo de subida. Esto detecta el caso en que alguien renombra shell.php a photo.jpg pero ni siquiera se molesta en falsificar la detección MIME, lo cual por sí solo sigue sin ser suficiente (vea la siguiente sección).
Verifique las firmas de archivo (magic bytes)
Todo formato de archivo real comienza con una secuencia específica de bytes — un “número mágico” — que identifica lo que realmente es, independientemente de su nombre de archivo o del tipo MIME declarado. Esta es la verificación que detecta a un atacante que renombra malware.exe a photo.jpg: la extensión dice imagen, pero los primeros bytes nunca mentirán.
JPEG FF D8 FF
PNG 89 50 4E 47
PDF 25 50 44 46
ZIP 50 4B 03 04
La comprobación de extensión, la comprobación MIME y la comprobación de magic bytes forman tres capas independientes. Un atacante debe derrotar las tres simultáneamente, y una de ellas — los magic bytes — está verificando el contenido real del archivo, no una etiqueta adjunta.
Aplique límites de tamaño de archivo y de cantidad de subidas
Los tamaños de archivo sin límites son por sí mismos un vector de denegación de servicio: suficientes subidas grandes agotan el espacio en disco, y leerlas por completo en memoria antes de validarlas agota la RAM. Establezca límites explícitos y específicos por tipo.
Images: 5 MB
PDF: 20 MB
Video: 100 MB
Combine los límites de tamaño con límites de cantidad: un número máximo de archivos por solicitud y un número máximo de subidas por usuario por día. Sin ambos, un atacante no necesita un solo archivo enorme para causar daño; un script que suba miles de archivos pequeños hace el mismo trabajo.
Escanee en busca de malware antes de publicar
Las comprobaciones de firma y MIME confirman el tipo de un archivo; no dicen nada sobre si un PDF o DOCX de apariencia legítima lleva un exploit incrustado o una carga útil de macros. Ese es el trabajo de un escáner de malware dedicado (ClamAV es la opción open source más común; varios proveedores cloud también ofrecen APIs de escaneo) ejecutado como su propia etapa de la canalización, antes de que un archivo se mueva al almacenamiento aprobado. Esto importa especialmente para los tipos de archivo que realmente son capaces de llevar lógica ejecutable — PDFs, documentos de Office y archivos ZIP — pero ejecutarlo de forma universal es un seguro barato.
Sanee y aleatorice los nombres de archivo
Nunca almacene un archivo con el nombre que el usuario le dio. Los nombres de archivo proporcionados por el usuario son cadenas controladas por el atacante, y allí viven dos problemas muy diferentes:
Colisiones de nombres de archivo y fuga de información. Dos usuarios que suben resume.pdf no deberían sobrescribirse entre sí, y no hay razón para exponer el nombre original en su capa de almacenamiento.
Path traversal. Un nombre de archivo como ../../../etc/passwd o ..\..\Windows\system32\config es una cadena, y si su código de almacenamiento la concatena ingenuamente a una ruta base, el atacante acaba de indicarle a su servidor dónde escribir. Genere un identificador nuevo y aleatorio para cada archivo almacenado, y nunca permita que la entrada del usuario toque directamente una ruta del sistema de archivos.
User uploads: resume.pdf
You store as: b8a8b39d-17fc-4fd2-a8f7-9e1c4d2a6f31.pdf
Almacene los archivos fuera de la raíz web y sírvalos a través de su aplicación
Si los archivos subidos viven dentro de un directorio que su servidor web expone directamente (/var/www/html/uploads/), entonces cualquier cosa que llegue allí — incluido un archivo que haya esquivado sus otras comprobaciones — es inmediatamente accesible por URL. Almacene las subidas fuera de la raíz web, o mejor aún, en object storage (S3, GCS, Azure Blob) completamente separado de sus servidores de aplicación, y sirva los archivos de vuelta mediante código de aplicación o URLs firmadas de corta duración que apliquen autorización en cada solicitud.
Nunca permita que los archivos subidos se ejecuten
Este es el control que convierte “el atacante subió un script malicioso” en un no-evento. Trate cada archivo subido como datos, nunca como código, sin importar qué tipo afirme ser. En concreto: desactive permisos de ejecución en los directorios de subida y asegúrese de que la configuración de su servidor web no entregue ningún tipo de archivo dentro de la ruta de subida a un intérprete (PHP, CGI o cualquier otro). Combinado con allowlisting y aislamiento del almacenamiento, esto es lo que realmente evita la ejecución remota de código: incluso si un archivo malicioso de alguna manera llega al disco, nunca se ejecuta.
El recorrido de la subida: de la solicitud al almacenamiento aprobado
Ayuda seguir una subida de extremo a extremo. Supongamos que un usuario está subiendo una foto de perfil a través de su API.
Paso 1: Solicitud y comprobación de autenticación
El cliente envía una solicitud POST con los datos de la imagen. Antes de tocar siquiera el contenido del archivo, el servidor comprueba la autenticación (¿es un usuario con sesión iniciada?) y la autorización (¿este usuario puede subir a este endpoint?), y luego compara la solicitud con el límite de tasa por usuario. Una solicitud que falle cualquiera de esas verificaciones se rechaza de inmediato: no se procesa ningún archivo para solicitudes no autenticadas o limitadas por tasa.
Paso 2: Almacenamiento temporal y no público
La subida en bruto se escribe en una ubicación temporal que nunca es accesible por la web: no es el destino final ni un bucket público. Esta es la etapa de “cuarentena”. Aquí todavía no se confía en nada.
Paso 3: Validación estructural
El servidor compara la extensión declarada con la allowlist, detecta el tipo MIME real a partir del contenido y verifica que los magic bytes coincidan con un formato de imagen real. El tamaño se comprueba con respecto al límite del campo. Si el archivo es, por ejemplo, en realidad un .zip renombrado a .jpg, falla aquí y no continúa.
Paso 4: Saneamiento del nombre de archivo
Se genera un nuevo nombre de archivo aleatorio. El nombre original se almacena como metadato si se necesita para mostrarlo, pero nunca toca una ruta del sistema de archivos.
Paso 5: Procesamiento específico del contenido
Como se trata de una imagen, el servidor la abre con una librería de imágenes confiable y la vuelve a codificar, escribiendo un archivo nuevo en lugar de confiar literalmente en los bytes subidos. Solo este paso neutraliza una gran clase de cargas útiles ocultas en metadatos de imagen o estructuras de imagen malformadas, y elimina datos EXIF (coordenadas GPS, modelo del dispositivo, marcas de tiempo) que los usuarios generalmente no pretenden compartir.
Paso 6: Escaneo de malware
El archivo recodificado se escanea antes de considerarse seguro. Un resultado limpio permite que el archivo continúe; un resultado positivo lo envía a una ubicación de dead-letter/cuarentena para revisión y la subida del usuario se rechaza.
Paso 7: Almacenamiento aprobado y entrega
Solo ahora el archivo se mueve a su ubicación permanente en object storage. El registro de base de datos de la foto de perfil del usuario se actualiza para apuntar al nuevo objeto, y se sirve a otros usuarios a través de la CDN o URL del object storage, nunca desde una ruta que un usuario pudiera adivinar o manipular directamente.
Observe la forma de esto: en cada paso, un fallo impide que el archivo avance en lugar de asumir por defecto “permitir”. Esa es toda la filosofía de una canalización segura de subida en una sola frase.
Manejo de tipos de archivo especiales
No todos los tipos de archivo conllevan el mismo riesgo, y algunos merecen un tratamiento específico más allá de la canalización general.
Imágenes
Las imágenes son el tipo de subida más común y, de forma engañosa, uno de los más fáciles de manejar mal. El patrón seguro es no confiar nunca directamente en los bytes de una imagen subida: abra el archivo con una librería confiable (Pillow en Python, Sharp en Node.js, ImageMagick cuidadosamente configurado), decodifíquelo y vuelva a codificarlo en un archivo nuevo. Esto tiene dos ventajas: elimina la mayoría de las cargas útiles que dependen de estructuras de imagen malformadas o scripts incrustados, y elimina metadatos EXIF que pueden filtrar la ubicación o información del dispositivo del usuario. Si la librería no puede decodificar el archivo como una imagen válida, eso es una señal fuerte de que no lo es: rechácelo.
Archivos ZIP
Los archivos ZIP merecen un escrutinio adicional porque son un contenedor, no un artefacto único: un ZIP pequeño puede expandirse a algo mucho más grande de lo que parece, una “ZIP bomb”. Un archivo de 5 KB que se descomprime en 500 GB agotará el espacio en disco en el momento en que lo extraiga. Antes de extraer nada, compruebe los metadatos del archivo: tamaño total descomprimido, número de entradas, relación de compresión y profundidad máxima de anidamiento (un ZIP dentro de un ZIP dentro de un ZIP). Rechace los archivos que superen umbrales razonables antes de la extracción, y sanee cada nombre de archivo dentro del archivo comprimido igual que sanearía un nombre de archivo subido: las entradas anidadas de path traversal son tan peligrosas como un único nombre malicioso.
Documentos de Office
DOCX, XLSX y formatos similares son en sí mismos contenedores ZIP con XML incrustado, y pueden transportar macros capaces de ejecutar código al abrirse. Si su aplicación solo necesita almacenar y más tarde mostrar estos archivos, no permita que los documentos de los usuarios finales autoejecuten macros en ninguna parte de su canalización y páselos por el escáner de malware como cualquier otro tipo de documento. Si necesita extraer contenido de ellos mediante programación, hágalo con una librería que analice el formato en lugar de una que lo abra en un entorno capaz de ejecutar código incrustado.
SVG y HTML
Los archivos SVG son XML, y XML puede contener etiquetas <script>: una subida de “imagen” SVG puede transportar JavaScript ejecutable que se ejecuta cuando el archivo se visualiza en un navegador. El enfoque más seguro es rechazar directamente las subidas SVG (solo raster), o servirlas con una cabecera Content-Disposition: attachment y una Content-Security-Policy estricta que impida la ejecución de cualquier script incrustado, en lugar de renderizarlas inline como contenido activo.
Escalado y desafíos en producción
Valide mientras transmite, no después de almacenar todo en búfer
Cargar un archivo grande completo en memoria antes de haber validado nada es en sí mismo un riesgo de agotamiento de recursos: un puñado de subidas grandes concurrentes puede agotar la memoria disponible antes incluso de que se ejecute su comprobación de tamaño. Allí donde su framework lo permita, valide límites de tamaño y lea magic bytes desde un stream de forma incremental, rechazando subidas sobredimensionadas o malformadas antes de que el archivo completo haya sido almacenado en búfer.
Aplique rate limiting en múltiples niveles
Un único rate limit global no es suficiente: aplique límites por usuario, por IP y por endpoint, ya que capturan diferentes patrones de abuso (una cuenta comprometida frente a un script distribuido frente a un único endpoint siendo machacado). Puntos de partida típicos son 20 subidas por minuto por usuario y un límite diario por cuenta, ajustados a lo que su producto realmente necesite.
Cuarentena y escaneo a escala
Con un alto volumen de subidas, la etapa de escaneo de malware se convierte en un cuello de botella de throughput si es síncrona. El mismo patrón que funciona para el procesamiento de trabajos en segundo plano funciona aquí: envíe los archivos a una cola después de la validación estructural inicial, deje que un pool de workers de escaneo los procese de forma asíncrona y solo cambie el estado de un archivo a “disponible” una vez completado el escaneo. Los usuarios ven un estado intermedio de “procesando”, lo cual es honesto, porque el archivo realmente aún no es seguro para servir.
Object storage y URLs firmadas
Servir archivos directamente desde sus servidores de aplicación no escala bien y mantiene los datos sensibles más cerca de su cómputo de lo necesario. Object storage (S3, GCS, Azure Blob) con URLs firmadas de corta duración resuelve ambos problemas: las subidas y descargas ocurren directamente entre el cliente y el almacenamiento, su aplicación solo emite URLs limitadas en el tiempo y con alcance definido, y el control de acceso se aplica en el momento en que se genera una URL en lugar de depender del secretismo.
Detalles de implementación específicos por lenguaje y framework
Para un backend en Python — Django, DRF o FastAPI — unas pocas librerías cubren la mayor parte de esta canalización:
python-magic(un binding sobrelibmagic) para detección de tipo MIME y firma de archivo, en lugar de confiar en el content type derequest.FILES.- Pillow para abrir, validar y recodificar imágenes, y para eliminar metadatos EXIF mediante
image.getexif()antes de guardar. zipfile(librería estándar) para inspeccionar el contenido de archivos comprimidos — número de entradas, tamaño comprimido frente a descomprimido y rutas anidadas — antes de llamar aextractall.uuid(librería estándar) para generar nombres de archivo aleatorios para almacenamiento.boto3para subir archivos validados a S3 y generar URLs presignadas con tiempo limitado tanto para subida como para descarga.
En Django, esta canalización suele vivir en un validador personalizado de FileField/ImageField más un método clean() en el formulario o serializer: la validación debe ejecutarse antes de que el archivo llegue siquiera a save(). En FastAPI, las mismas comprobaciones pertenecen a una dependency o a una función de validación llamada explícitamente dentro del endpoint, aplicada a UploadFile antes de que se escriba en cualquier lugar permanente. En ambos casos, resista la tentación de depender únicamente de utilidades del framework estilo FileExtensionValidator: normalmente comprueban el nombre del archivo, no el contenido, que es exactamente la brecha que la validación de magic bytes existe para cerrar.
Construir su canalización de seguridad de subida: ejemplos de código
A continuación se muestran implementaciones funcionales, en Python, de las comprobaciones que más importan. Están pensadas para adaptarse a la capa de validación de su framework, no para insertarlas sin modificar.
Validación de magic bytes — comprobando firmas reales de archivo frente a los tipos esperados:
MAGIC_BYTES = {
"image/jpeg": [b"\xFF\xD8\xFF"],
"image/png": [b"\x89PNG\r\n\x1a\n"],
"application/pdf": [b"%PDF-"],
}
def verify_magic_bytes(file_bytes: bytes, expected_mime: str) -> bool:
signatures = MAGIC_BYTES.get(expected_mime)
if not signatures:
return False
return any(file_bytes.startswith(sig) for sig in signatures)
Detección de MIME del lado del servidor con python-magic, comparada con lo que afirma el cliente:
import magic
def detect_mime_type(file_bytes: bytes) -> str:
return magic.from_buffer(file_bytes, mime=True)
def validate_upload(file_bytes: bytes, allowed_mimes: set[str]) -> bool:
detected = detect_mime_type(file_bytes)
if detected not in allowed_mimes:
return False
return verify_magic_bytes(file_bytes, detected)
Generación segura de nombres de archivo — nunca confíe en el nombre original para almacenamiento:
import uuid
from pathlib import PurePosixPath
def safe_storage_name(original_filename: str) -> str:
extension = PurePosixPath(original_filename).suffix.lower()
allowed_extensions = {".jpg", ".jpeg", ".png", ".pdf", ".docx"}
if extension not in allowed_extensions:
raise ValueError("Extension not allowed")
return f"{uuid.uuid4()}{extension}"
Recodificación de imágenes y eliminación de EXIF con Pillow:
from io import BytesIO
from PIL import Image
def reencode_image(file_bytes: bytes, max_dimension: int = 4096) -> bytes:
image = Image.open(BytesIO(file_bytes))
image.verify() # raises if the file isn't a valid image
image = Image.open(BytesIO(file_bytes)) # reopen after verify()
image.thumbnail((max_dimension, max_dimension))
output = BytesIO()
# Saving without the original EXIF block strips metadata by default
image.convert("RGB").save(output, format="JPEG", quality=85)
return output.getvalue()
Protección contra ZIP bomb — inspeccionando un archivo antes de extraer nada:
import zipfile
MAX_UNCOMPRESSED_SIZE = 200 * 1024 * 1024 # 200 MB
MAX_ENTRIES = 1000
MAX_COMPRESSION_RATIO = 100
def is_zip_safe(zip_path: str) -> bool:
with zipfile.ZipFile(zip_path) as archive:
infos = archive.infolist()
if len(infos) > MAX_ENTRIES:
return False
total_uncompressed = 0
for info in infos:
if ".." in info.filename or info.filename.startswith("/"):
return False # path traversal attempt
total_uncompressed += info.file_size
if total_uncompressed > MAX_UNCOMPRESSED_SIZE:
return False
if info.compress_size > 0:
ratio = info.file_size / info.compress_size
if ratio > MAX_COMPRESSION_RATIO:
return False
return True
Rate limiting simple por usuario con Redis:
import time
import redis
r = redis.Redis()
def check_upload_rate_limit(user_id: str, max_per_minute: int = 20) -> bool:
key = f"upload_rate:{user_id}:{int(time.time() // 60)}"
count = r.incr(key)
if count == 1:
r.expire(key, 60)
return count <= max_per_minute
Cada uno de estos es una etapa de la canalización descrita anteriormente: están pensados para encadenarse, no para usarse de forma aislada.
Errores comunes y cómo evitarlos
Comprobar solo la extensión
Error: Rechazar subidas basándose solo en la extensión del nombre del archivo. Un atacante renombra shell.php a shell.jpg y la comprobación pasa.
Solución: Combine extensión, detección MIME del lado del servidor y verificación de magic bytes. Las tres deben coincidir antes de aceptar un archivo.
Confiar en el Content-Type proporcionado por el cliente
Error: Leer request.headers['Content-Type'] y tratarlo como verdad absoluta. Es un valor que el cliente eligió enviar, no un hecho validado sobre el archivo.
Solución: Detecte el tipo MIME del lado del servidor a partir de los bytes reales del archivo y trate la cabecera del cliente como, en el mejor de los casos, informativa.
Extraer archivos ZIP sin límites
Error: Llamar a extractall() sobre un archivo subido sin ninguna comprobación. Un archivo diminuto puede descomprimirse en cientos de gigabytes y tumbar el servidor.
Solución: Inspeccione el número de entradas, el tamaño total descomprimido y la relación de compresión antes de extraer nada, como se mostró antes. Rechace archivos que superen umbrales razonables.
Servir subidas desde un directorio accesible por la web
Error: Almacenar subidas directamente dentro del directorio que el servidor web expone (/var/www/html/uploads/), haciendo que cualquier cosa que llegue allí sea instantáneamente accesible por URL.
Solución: Almacene fuera de la raíz web, idealmente en object storage completamente separado de los servidores de aplicación, y sirva a través de lógica autenticada de la aplicación o URLs firmadas.
Omitir la autorización en la descarga
Error: Asumir que, porque un nombre de archivo almacenado es un UUID aleatorio, es seguro servirlo a cualquiera que lo solicite. La oscuridad no es autorización: la URL terminará filtrándose, ya sea mediante logs, cabeceras referrer o un enlace compartido.
Solución: Verifique en cada descarga que el usuario solicitante sea propietario del archivo o haya recibido acceso a él, no solo en la subida.
Tratar la validación como una barrera única
Error: Validar un archivo una vez en el momento de la subida y asumir que es seguro para siempre después, incluso cuando más tarde lo procese una parte distinta del sistema (por ejemplo, un trabajo en segundo plano que abre el archivo con una librería diferente).
Solución: Mantenga explícitas las garantías de la canalización: un archivo que ha pasado validación y escaneo es seguro para almacenarse y servirse tal cual; no es una garantía universal para cualquier operación futura realizada sobre él. Los consumidores aguas abajo deben seguir abriendo archivos de forma defensiva.
Mejores prácticas para producción
Registre la actividad de subida con suficiente detalle para investigar incidentes — ID de usuario, dirección IP, nombre de archivo generado, tipo MIME detectado, tamaño y resultado del escaneo. Evite registrar el contenido del archivo en sí.
Ponga siempre en cuarentena antes de publicar. Un archivo nunca se mueve al almacenamiento aprobado ni se pone a disposición de otros usuarios hasta que cada etapa de la canalización — validación estructural, procesamiento de contenido y escaneo de malware — se haya completado correctamente.
Falle de forma cerrada, no abierta. Si el escáner de malware no está disponible temporalmente, el comportamiento correcto es retener el archivo en cuarentena, no saltarse el escaneo y publicarlo de todos modos.
Pruebe la canalización con entradas adversarias, no solo con válidas. Ejecutables renombrados, archivos sobredimensionados, archivos ZIP profundamente anidados y nombres de archivo con path traversal deberían formar parte de su suite de pruebas: el objetivo es demostrar que cada etapa realmente rechaza lo que afirma rechazar.
Implemente deliberadamente nuevos tipos de archivo aceptados. Añadir una nueva extensión a la allowlist amplía su superficie de ataque; trátelo con el mismo cuidado que cualquier otro cambio relevante para la seguridad, incluida una revisión de cómo se validará, procesará y servirá ese tipo de archivo.
Revise las políticas de almacenamiento y retención para el contenido subido, especialmente cualquier cosa que contenga datos personales: decida cuánto tiempo se conservan los archivos y asegúrese de que la eliminación realmente los quite de cada capa de almacenamiento, no solo del registro principal en la base de datos.
Cierre
La seguridad en la subida de archivos no es una sola función de validación: es una canalización donde cada etapa es responsable de cerrar una brecha específica, y un archivo solo se gana la confianza al sobrevivir a todas ellas. Use allowlist para lo que acepta, verifique el contenido en lugar de los nombres de archivo, nunca permita que los archivos subidos se ejecuten, aísle el almacenamiento de su raíz web y no considere un archivo seguro hasta que realmente haya sido escaneado.
La mayoría de las vulnerabilidades de subida en sistemas de producción se remontan a haber omitido una de estas capas, no a alguna técnica de ataque exótica, lo cual es una buena noticia, porque significa que la solución casi siempre es directa de implementar una vez que usted sabe qué capa falta.
¿Se ha encontrado con una vulnerabilidad de subida en algún sistema en el que haya trabajado, o con una capa de validación que resultó importar más de lo esperado? Me encantará saberlo.
