Las subidas de archivos seguras en apps web requieren permisos estrictos, límites de tamaño, URLs firmadas y patrones sencillos de escaneo para evitar incidentes.

Las subidas de archivos parecen inofensivas: una foto de perfil, un PDF, una hoja de cálculo. Pero a menudo son el primer incidente de seguridad porque permiten que extraños envíen a tu sistema una caja misteriosa. Si la aceptas, la almacenas y se la muestras a otras personas, has creado una nueva vía para atacar tu app.
El riesgo no es solo “alguien sube un virus”. Una subida maliciosa puede filtrar archivos privados, disparar tu factura de almacenamiento o engañar a usuarios para que entreguen acceso. Un archivo llamado “invoice.pdf” puede no ser un PDF en absoluto. Incluso PDFs e imágenes legítimas pueden causar problemas si tu app confía en metadatos, genera vistas previas automáticamente o los sirve con reglas equivocadas.
Los fallos reales suelen verse así:
Un detalle impulsa muchos incidentes: almacenar archivos no es lo mismo que servirlos. El almacenamiento es donde guardas los bytes. Servir es cómo esos bytes se entregan a navegadores y apps. Las cosas se complican cuando una app sirve subidas de usuarios con el mismo nivel de confianza y reglas que el sitio principal, de modo que el navegador trata la subida como “confiable”.
“Lo suficientemente seguro” para una app pequeña o en crecimiento suele significar que puedas responder cuatro preguntas sin evasivas: quién puede subir, qué aceptas, cuánto y con qué frecuencia, y quién puede leerlo después. Incluso si construyes rápido (con código generado o una plataforma guiada por chat), esos límites siguen importando.
Trata cada subida como entrada no confiable. La forma práctica de mantener las subidas seguras es imaginar quién podría abusar de ellas y qué significa “éxito” para ese atacante.
La mayoría de los atacantes son bots que buscan formularios de subida débiles o usuarios reales que empujan límites para obtener almacenamiento gratis, rascar datos o trolear el servicio. A veces también es un competidor que prueba fugas o caídas.
¿Qué buscan? Normalmente uno de estos resultados:
Luego mapea los puntos débiles. El endpoint de subida es la puerta principal (archivos sobredimensionados, formatos extraños, tasas altas de petición). El almacenamiento es la trastienda (buckets públicos, permisos incorrectos, carpetas compartidas). Las URLs de descarga son la salida (predecibles, de larga vida o no ligadas a un usuario).
Ejemplo: una funcionalidad de “subir curriculum”. Un bot sube miles de PDFs grandes para subir costos, mientras que un usuario abusivo sube un archivo HTML y lo comparte como “documento” para engañar a otros.
Antes de añadir controles, decide qué importa más para tu app: privacidad (quién puede leer), disponibilidad (puedes seguir sirviendo), coste (almacenamiento y ancho de banda) y cumplimiento (dónde se guarda y cuánto tiempo). Esa lista de prioridades mantiene las decisiones coherentes.
La mayoría de los incidentes con subidas no son ataques sofisticados. Son errores simples de “puedo ver el archivo de otra persona”. Trata los permisos como parte de las subidas, no como una característica añadida después.
Comienza con una regla: denegar por defecto. Asume que cada objeto subido es privado hasta que permitas explícitamente el acceso. “Privado por defecto” es una buena base para facturas, archivos médicos, documentos de cuenta y cualquier cosa ligada a un usuario. Haz públicos los archivos solo cuando el usuario lo espere claramente (como un avatar público), y aun así considera acceso con tiempo limitado.
Mantén los roles simples y separados. Una división común es:
No confíes en reglas a nivel de carpeta como “todo en /user-uploads/ está bien”. Comprueba la propiedad o el acceso del tenant en tiempo de lectura, para cada archivo. Eso te protege cuando alguien cambia de equipo, sale de la organización o un archivo se reasigna.
Un buen patrón de soporte es estrecho y temporal: otorga acceso a un archivo específico, regístralo y expíralo automáticamente.
La mayoría de los ataques en subidas comienzan con un truco simple: un archivo que parece seguro por su nombre o el encabezado del navegador, pero en realidad es otra cosa. Trata todo lo que envía el cliente como no confiable.
Comienza con una lista blanca: decide los formatos exactos que aceptas (por ejemplo, .jpg, .png, .pdf) y rechaza todo lo demás. Evita “cualquier imagen” o “cualquier documento” a menos que realmente lo necesites.
No confíes en la extensión del nombre ni en el encabezado Content-Type del cliente. Ambos son fáciles de falsificar. Un archivo llamado invoice.pdf puede ser ejecutable, y Content-Type: image/png puede ser mentira.
Un enfoque más fuerte es inspeccionar los primeros bytes del archivo, a menudo llamados “magic bytes” o firma del archivo. Muchos formatos comunes tienen encabezados consistentes (como PNG y JPEG). Si el encabezado no coincide con lo permitido, recházalo.
Una configuración práctica de validación:
Renombrar importa más de lo que parece. Si almacenas nombres proporcionados por el usuario directamente, invites trucos de rutas, caracteres extraños y sobreescrituras accidentales. Usa un ID generado para el almacenamiento y guarda el nombre original solo para mostrar.
Para fotos de perfil, acepta solo JPEG y PNG, verifica encabezados y elimina metadatos si puedes. Para documentos, considera limitar a PDF y rechazar contenido activo. Si más adelante decides permitir SVG o HTML, trátalos como potencialmente ejecutables y aíslalos.
La mayoría de las caídas por subidas no son “trucos sofisticados”. Son archivos enormes, demasiadas peticiones o conexiones lentas que ocupan servidores hasta que la app parece caída. Trata cada byte como un coste.
Elige un máximo por característica, no un número global. Un avatar no necesita el mismo límite que un documento fiscal o un video corto. Fija el límite más pequeño que siga pareciendo normal, y añade una ruta de “subida grande” separada solo cuando realmente la necesites.
Haz cumplir los límites en más de un lugar, porque los clientes pueden mentir: en la lógica de la app, en el servidor web o proxy reverso, con timeouts de subida y con rechazo temprano cuando el tamaño declarado es demasiado grande (antes de leer todo el cuerpo).
Ejemplo concreto: avatares limitados a 2 MB, PDFs hasta 20 MB, y todo lo mayor requiere un flujo distinto (como subida directa a object storage con una URL firmada).
Hasta los archivos pequeños pueden convertirse en DoS si alguien los sube en bucle. Añade rate limits en endpoints de subida por usuario y por IP. Considera límites más estrictos para tráfico anónimo que para usuarios autenticados.
Las subidas reanudables ayudan a usuarios reales con redes malas, pero el token de sesión debe ser estricto: expiración corta, ligado al usuario y atado a un tamaño y destino específico. Si no, los endpoints de “resume” se convierten en un conducto gratuito hacia tu almacenamiento.
Cuando bloquees una subida, devuelve errores claros para el usuario (archivo demasiado grande, demasiadas peticiones) pero no reveles detalles internos (stack traces, nombres de buckets, detalles de proveedores).
Las subidas seguras no se tratan solo de lo que aceptas. También importan dónde va el archivo y cómo se lo devuelves después.
Mantén los bytes de subida fuera de tu base de datos principal. La mayoría de las apps solo necesitan metadatos en la BD (ID del propietario, nombre de archivo original, tipo detectado, tamaño, checksum, clave de almacenamiento, tiempo de creación). Guarda los bytes en object storage o un servicio de archivos hecho para blobs grandes.
Separa archivos públicos y privados a nivel de almacenamiento. Usa distintos buckets o contenedores con reglas diferentes. Los archivos públicos (como avatares públicos) pueden leerse sin login. Los archivos privados (contratos, facturas, documentos médicos) nunca deberían ser legibles públicamente, ni siquiera si alguien adivina la URL.
Evita servir archivos de usuario desde el mismo dominio que tu app cuando puedas. Si se cuela un archivo arriesgado (HTML, SVG con scripts o rarezas de sniffing MIME del navegador), alojarlo en tu dominio principal puede convertirse en una toma de cuenta. Un dominio de descarga dedicado (o dominio de almacenamiento) limita el radio de impacto.
En las descargas, fuerza cabeceras seguras. Establece un Content-Type predecible basado en lo que permites, no en lo que afirma el usuario. Para cualquier cosa que el navegador pueda interpretar, prefiere enviarlo como descarga.
Algunos valores por defecto que evitan sorpresas:
Content-Disposition: attachment para documentos.Content-Type seguro (o application/octet-stream).La retención también es seguridad. Borra subidas abandonadas, elimina versiones antiguas tras reemplazos y establece límites temporales para archivos temporales. Menos datos almacenados significa menos que filtrar.
Las URLs firmadas (a menudo llamadas pre-signed URLs) son una forma común de permitir que usuarios suban o descarguen archivos sin hacer público tu bucket de almacenamiento y sin enviar cada byte por tu API. La URL lleva permiso temporal y luego expira.
Dos flujos comunes:
Directo a almacenamiento reduce la carga de la API, pero hace que las reglas de almacenamiento y las restricciones de la URL sean más importantes.
Trata una URL firmada como una llave de un solo uso. Hazla específica y de corta duración.
Un patrón práctico es crear primero un registro de subida (estado: pending), luego emitir la URL firmada. Tras la subida, confirma que el objeto existe y coincide en tamaño y tipo esperado antes de marcarlo como listo.
Un flujo seguro de subidas es sobre todo reglas claras y estado claro. Trata cada subida como no confiable hasta que las comprobaciones lo confirmen.
Escribe lo que permite cada funcionalidad. Una foto de perfil y un documento fiscal no deberían compartir los mismos tipos de archivo, límites de tamaño ni visibilidad.
Define tipos permitidos y un límite de tamaño por funcionalidad (por ejemplo: fotos hasta 5 MB; PDFs hasta 20 MB). Hacer cumplir las mismas reglas en el backend.
Crea un “registro de subida” antes de que lleguen los bytes. Guarda: propietario (usuario u organización), propósito (avatar, factura, adjunto), nombre de archivo original, tamaño máximo esperado y un estado como pending.
Sube a una ubicación privada. No dejes que el cliente elija la ruta final.
Valida otra vez en servidor: tamaño, magic bytes/tipo, lista blanca. Si pasa, cambia el estado a uploaded.
Escanea en busca de malware y actualiza el estado a clean o quarantined. Si el escaneo es asíncrono, mantén el acceso bloqueado mientras esperas.
Permite descarga, vista previa o procesamiento solo cuando el estado sea clean.
Pequeño ejemplo: para una foto de perfil, crea un registro ligado al usuario con propósito avatar, almacénala de forma privada, confirma que es realmente JPEG/PNG (no solo por el nombre), escanéala y luego genera una URL de vista previa.
El escaneo de malware es una red de seguridad, no una promesa. Detecta archivos malos conocidos y trucos obvios, pero no lo detectará todo. El objetivo es simple: reducir riesgo y hacer que los archivos desconocidos sean inofensivos por defecto.
Un patrón fiable es cuarentena primero. Guarda cada nueva subida en una ubicación privada y márcala como pendiente. Solo después de que pase las comprobaciones la mueves a una ubicación “clean” (o la marcas como disponible).
Los escaneos síncronos funcionan solo para archivos pequeños y tráfico bajo porque el usuario espera. La mayoría de las apps escanean de forma asíncrona: aceptan la subida, devuelven un estado de “procesando” y escanean en segundo plano.
El escaneo básico suele ser un motor antivirus (o servicio) más algunas salvaguardas: escaneo AV, comprobaciones de tipo de archivo (magic bytes), límites en archivos comprimidos (zip bombs, zips anidados, tamaño descomprimido enorme) y bloquear formatos que no necesitas.
Si el escáner falla, se queda colgado o devuelve “desconocido”, trata el archivo como sospechoso. Mantenlo en cuarentena y no proporciones un enlace de descarga. Ahí es donde los equipos se queman: “escaneo falló” no debe convertirse en “igual lo publicamos”.
Cuando bloquees un archivo, mantén el mensaje neutral: “No pudimos aceptar este archivo. Prueba con otro archivo o contacta con soporte.” No digas que detectaste malware a menos que estés seguro.
Considera dos funcionalidades: una foto de perfil (mostrada públicamente) y un recibo PDF (privado, usado para facturación o soporte). Ambas son problemas de subida, pero no deberían compartir las mismas reglas.
Para la foto de perfil, manténlo estricto: permite solo JPEG/PNG, capea el tamaño (por ejemplo 2–5 MB), y re-encodifica en servidor para no servir los bytes originales del usuario. Almacénala en público solo después de las comprobaciones.
Para el recibo PDF, acepta un tamaño mayor (por ejemplo hasta 20 MB), mantenlo privado por defecto y evita renderizarlo inline desde el dominio principal de tu app.
Un modelo de estados simple mantiene informados a los usuarios sin exponer internos:
Las URLs firmadas encajan bien aquí: usa una URL firmada de corta duración para la subida (solo escritura, una clave de objeto). Emite una URL firmada separada y también corta para la lectura, y solo cuando el estado sea clean.
Registra lo que necesitas para investigar, no el archivo en sí: ID de usuario, ID de archivo, tipo estimado, tamaño, clave de almacenamiento, marcas de tiempo, resultado del escaneo, IDs de petición. Evita registrar contenido bruto o datos sensibles dentro de documentos.
La mayoría de los bugs de subida ocurren porque un pequeño atajo “temporal” se vuelve permanente. Asume que cada archivo es no confiable, que cada URL se compartirá y que cada “lo arreglaremos después” se olvidará.
Las trampas que aparecen repetidamente:
Content-Type equivocado, permitiendo que el navegador interprete contenido riesgoso.La monitorización es lo que los equipos se saltan hasta que la factura de almacenamiento se dispara. Sigue volumen de subidas, tamaño medio, principales subidores y tasas de error. Una cuenta comprometida puede subir silenciosamente miles de archivos grandes durante la noche.
Ejemplo: un equipo almacena avatares bajo nombres proporcionados por usuarios como “avatar.png” en una carpeta compartida. Un usuario sobrescribe las imágenes de otros. La solución es aburrida pero efectiva: genera claves de objeto en el servidor, mantiene las subidas privadas por defecto y expone una imagen redimensionada mediante una respuesta controlada.
Usa esto como repaso final antes de lanzar. Trata cada elemento como bloqueador de lanzamiento, porque la mayoría de los incidentes vienen de una salvaguarda faltante.
Content-Type predecible, nombres de archivo seguros y attachment para documentos.Escribe tus reglas en lenguaje llano: tipos permitidos, tamaños máximos, quién puede acceder a qué, cuánto viven las URLs firmadas y qué significa “escaneo aprobado”. Eso se convierte en el contrato compartido entre producto, ingeniería y soporte.
Añade algunas pruebas que detecten fallos comunes: archivos sobredimensionados, ejecutables renombrados, lecturas no autorizadas, URLs firmadas expiradas y descargas con “escaneo en proceso”. Estas pruebas son baratas comparadas con un incidente.
Si construyes e iteras rápido, ayuda usar un flujo donde puedas planear cambios y revertirlos con seguridad. Los equipos que usan Koder.ai (koder.ai) suelen apoyarse en el modo de planificación y snapshots/rollback mientras endurecen las reglas de subida con el tiempo, pero el requisito central sigue siendo: la política la hace cumplir el backend, no la interfaz.
Comienza con privado por defecto y trata cada subida como entrada no confiable. Implemente cuatro controles básicos en el servidor:
Si puedes responder a estas preguntas con claridad, ya estás por delante de la mayoría de los incidentes.
Porque los usuarios pueden subir una “caja misteriosa” que tu app almacena y quizá vuelva a mostrar a otros usuarios. Eso puede provocar:
Rara vez se trata solo de “alguien subió un virus”.
Almacenar es guardar bytes en algún lugar. Servir es cómo esos bytes se entregan a navegadores y apps.
El peligro aparece cuando tu app sirve subidas de usuarios con el mismo nivel de confianza y reglas que tu sitio principal. Si un archivo riesgoso se trata como una página normal, el navegador puede ejecutarlo (o los usuarios confiar demasiado en él).
Un valor predeterminado más seguro es: almacenar de forma privada y luego servir mediante respuestas controladas con cabeceras seguras.
Usa denegar por defecto y comprueba el acceso cada vez que se descarga o previsualiza un archivo.
Reglas prácticas:
No confíes en la extensión del nombre de archivo ni en el Content-Type del navegador. Valida en el servidor:
Porque las caídas suelen venir de abusos sencillos: demasiadas subidas, archivos gigantes o conexiones lentas que agotan recursos.
Valores por defecto que funcionan bien:
Trata cada byte como un coste y cada solicitud como un posible abuso.
Sí, pero con cuidado. Las URLs firmadas permiten que el navegador suba/descargue directamente desde el almacenamiento sin hacer el bucket público.
Buenas prácticas:
Directo a almacenamiento reduce la carga de la API, pero el alcance y la expiración son innegociables.
El patrón más seguro es:
pendingEl escaneo ayuda, pero no es una garantía. Úsalo como red de seguridad, no como único control.
Enfoque práctico:
La clave es la política: “no escaneado” nunca debería significar “disponible”.
Entrega los archivos de forma que evites que los navegadores los interpreten como páginas web.
Buenos valores por defecto:
Content-Disposition: attachment para documentos/uploads/ está bien”La mayoría de los errores reales son simples errores de “puedo ver el archivo de otro usuario”.
Si los bytes no coinciden con un formato permitido, rechaza la subida.
cleanquarantinedcleanEsto evita que archivos “escaneo fallido” o “en proceso” se compartan accidentalmente.
Content-Type seguro elegido por el servidor (o application/octet-stream)Esto reduce el riesgo de que un archivo subido se convierta en una página de phishing o ejecute scripts.