Ir al contenido
LUIGI MICCA

Publicado

4 min de lectura

Subir un archivo es la función que peor escribimos

Un input, un endpoint, un disco. Parece el ticket más simple del sprint y es el que más vulnerabilidades concentra por línea de código. Las siete decisiones que toda subida tiene que tomar bien, por qué los frameworks te dejan solo justo ahí, y cómo revisar la tuya en una tarde.

Hace unas semanas construí, con una app de prueba, la función de "adjuntar una captura a un mensaje". Doscientas líneas. No porque el problema fuera difícil, sino porque es muchos problemas pequeños disfrazados de uno, y el framework — cualquier framework — se retira justo donde empiezan. Te da el multipart/form-data decodificado y te desea suerte.

Estas son las siete decisiones que encontré, en el orden en que aparecen, con lo que pasa cuando se toman mal. Ninguna es exótica. Todas están en producción, hoy, en apps que facturan.

1. El tipo de contenido es una afirmación, no una prueba

Content-Type: image/png lo escribe el cliente. Un cliente hostil escribe lo que quiera. Si tu código decide "es una imagen" leyendo la cabecera, estás almacenando — y luego sirviendo — ejecutables, HTML con scripts, SVG con JavaScript dentro, con una etiqueta que dice imagen. La única fuente de verdad son los primeros bytes del archivo: olfatéalos, decide por ellos, y descarta la cabecera como lo que es, una sugerencia.

2. El nombre del archivo es una ruta esperando a serlo

../../etc/passwd sigue funcionando en 2026 porque sigue habiendo código que hace join(uploadsDir, file.name). El nombre que envía el usuario sirve para mostrárselo a él. Para el disco, genera uno tú: aleatorio, sin extensión que venga del cliente, y guarda el nombre original en la base de datos como un dato más, con las mismas comillas que cualquier otro.

3. El tope se mide en bytes que llegan, no en cabeceras

Content-Length también la escribe el cliente, y una subida chunked ni siquiera la tiene. Un límite que lee la cabecera protege contra clientes honestos. El límite real cuenta los bytes conforme entran y corta cuando se pasa, antes de que el archivo entero esté en memoria o en disco — y responde con un 413 que el cliente pueda entender, no con una conexión caída que parecerá un bug de red.

4. El almacén vive fuera del proyecto

Guardar en ./uploads dentro del directorio de la app es cómodo hasta que lo despliegas. En desarrollo, el vigilante de archivos recarga el servidor con cada subida. En producción, el siguiente despliegue borra la carpeta, o el contenedor la pierde al reiniciarse, o — mi favorito — un volumen montado debajo de un directorio que el Dockerfile preparó con permisos aparece con otros permisos y cada subida responde 500. El almacén es infraestructura: un directorio fuera del árbol, declarado, con un propietario conocido, o un bucket. Decídelo el primer día, no el día del incidente.

5. La descarga vuelve a decidir todo

Aquí está el error más caro, y el más común: la subida comprueba que el usuario es miembro del espacio, el archivo se guarda bajo una URL, y la URL se sirve a quien la pida. La autorización existió un instante y luego se evaporó. La descarga tiene que rehacer la cadena entera — ¿pertenece al espacio?, ¿puede ver este canal?, ¿existe aún el mensaje? — antes de abrir el archivo. Si la cadena es cara, cáchala tú; no la omitas.

6. inline o attachment es una decisión de seguridad

Servir un archivo "inline" significa que el navegador intenta mostrarlo. Para una imagen es lo esperado. Para un HTML o un SVG subido por un usuario, es ejecutar su contenido en tu origen, con las cookies de tu usuario. La regla corta: inline solo para tipos que olfateaste como seguros, attachment para el resto, y X-Content-Type-Options: nosniff siempre, para que el navegador no decida por su cuenta algo distinto de lo que tú decidiste.

7. Nada de esto puede quedar en una caché compartida

Si la descarga se decidió por usuario, la respuesta es de ese usuario. Cache-Control: private — o directamente no-store — es lo que impide que un proxy o una CDN le entregue a la siguiente persona el archivo que autorizaste para la anterior. Es una cabecera, cuesta nada, y su ausencia es una fuga que no aparece en ningún log.

Por qué el framework te deja solo

No es mala fe. Cada una de estas decisiones depende de tu modelo — qué espacios existen, quién pertenece a qué, qué tipos admites — y un framework que las tomara por ti las tomaría mal para alguien. Lo que sí puede darte es el borde: un decodificador que capa por bytes, un tipo que represente el archivo sin fingir que su nombre es una ruta, y cabeceras seguras por defecto. Lo que no puede darte es la cadena del punto 5. Esa es tuya, y es donde pasan las cosas.

La revisión de una tarde

Abre el endpoint de subida de tu aplicación y busca estas siete líneas. La cabecera de tipo leída como verdad. El nombre del usuario en una ruta. El límite tomado de Content-Length. El directorio dentro del proyecto. La URL de descarga sin autorización propia. Un inline sin olfato previo. Una respuesta sin private. Si encuentras tres, no eres descuidado: eres la media. Pero la media es exactamente lo que buscan.