12k
All articles

Gestión de subidas de imágenes en el servidor

Seguridad de carga de imágenes en Node: valida firmas en memoria, limita el tamaño, re-encoda con sharp y sirve archivos con seguridad.

OpenReplay Team
OpenReplay Team
Gestión de subidas de imágenes en el servidor

La validación de archivos subidos en el servidor comienza en cuanto llega la petición multipart y, a esas alturas, lo único en lo que vale la pena confiar son los bytes. Tanto el Content-Type como el nombre del archivo provienen del navegador, así que trátalos como afirmaciones y no como hechos. Si has llegado aquí desde los artículos sobre el lado del navegador acerca de crear miniaturas de imágenes antes de subirlas y convertir imágenes a Base64 con Canvas, este es el extremo receptor de esa canalización.

Las dos mitades de la canalización cumplen funciones distintas. El redimensionado y la compresión en el navegador son una cortesía hacia el usuario; la validación en el servidor es lo que detiene un cuerpo de 2 GB o un script disfrazado con una extensión .jpg. Este artículo recorre cinco controles que necesita un endpoint de imágenes en Node: verificación de firmas, límites de tamaño por capas, nombres de archivo generados, recodificación y entrega segura.

Puntos clave

  • La cabecera Content-Type y el nombre del archivo en una subida multipart los define el cliente, por lo que un servidor que valide cualquiera de los dos está validando la afirmación del atacante sobre el archivo, no el archivo en sí.
  • Una verificación de firma debe leer los primeros bytes del archivo en memoria, antes de que nada llegue al disco, y compararlos con los formatos que este endpoint concreto acepta.
  • Los límites de tamaño corresponden primero al proxy, en segundo lugar a la opción limits de Multer y, por último, al código de la aplicación; el fileSize de Multer tiene por defecto Infinity, así que un límite sin establecer equivale a no tener límite.
  • La recodificación con sharp es lo que vuelve irrelevante la falsificación de firmas: la salida es un archivo nuevo, y los metadatos EXIF, incluidas las coordenadas GPS de las fotos de móvil, no sobreviven al proceso.
  • Sirve las imágenes almacenadas con un Content-Type tomado de tu propio registro de validación, junto con X-Content-Type-Options: nosniff, y usa una ruta de almacenamiento fuera del web root.

¿Por qué fallan las comprobaciones de tipo MIME y extensión?

Comprobar file.mimetype o la extensión del nombre del archivo no valida el archivo, porque el cliente suministra ambos valores. Dos artículos anteriores de este blog daban ese consejo: Multer NPM: File Upload in Node.js muestra un fileFilter que acepta cualquier archivo cuyo mimetype aparezca en una lista permitida, y Safe User Input Handling in Node.js recomienda validar los tipos MIME en el nivel del parser. Ambas comprobaciones merecen conservarse como rechazos tempranos y económicos, pero ninguna constituye un control de seguridad.

La falsificación requiere una sola línea:

curl -F "file=@payload.sh;type=image/jpeg" https://example.com/upload

Multer copia ese valor de type directamente en req.file.mimetype. Tu filtro ve image/jpeg; el cuerpo es un script de shell. La OWASP File Upload Cheat Sheet es explícita al señalar que la validación debe basarse en el contenido del archivo, no en metadatos suministrados por el cliente.

La validación de subidas empieza por la firma del archivo

Lee los primeros bytes del buffer, en memoria, antes de escribir nada en disco, y compáralos con los formatos que este endpoint concreto acepta. Las dos mitades de esa frase importan. Es habitual que los tutoriales validen después de que el archivo ya haya aterrizado en un directorio de uploads, lo que significa que un payload excesivo u hostil ya ha causado su daño antes de que se ejecute la comprobación. Y también es habitual que acepten un archivo si coincide con cualquier firma conocida, de modo que un PDF pasa sin problemas por un endpoint de avatares. Un endpoint de avatares que reconoce PDFs tiene un fallo de validación, no una funcionalidad.

Las firmas en sí: JPEG comienza con FF D8 FF, PNG comienza con la secuencia completa de 8 bytes definida en la especificación PNG, y WebP requiere RIFF en el offset 0 más WEBP en los bytes 8 a 11, según la especificación del contenedor WebP. Los bytes 4 a 7 corresponden al tamaño del chunk RIFF, razón por la cual una comprobación de WebP debe saltárselos, y razón por la cual una lectura ingenua de 4 bytes no gestiona correctamente ni PNG ni WebP.

const SIGNATURES = {
  "image/jpeg": (b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
  "image/png": (b) =>
    b.length >= 8 &&
    b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
  "image/webp": (b) =>
    b.length >= 12 &&
    b.subarray(0, 4).toString("ascii") === "RIFF" &&
    b.subarray(8, 12).toString("ascii") === "WEBP",
};

// Allowlist is per endpoint: this one accepts photos, nothing else
function detectImageType(buffer, allowed = ["image/jpeg", "image/png", "image/webp"]) {
  return allowed.find((type) => SIGNATURES[type](buffer)) ?? null;
}

Una advertencia: las firmas pueden falsificarse añadiendo los bytes correctos al principio de cualquier payload, en segundos. El paquete file-type lo dice en su propio README, donde la coincidencia de números mágicos se describe como una pista sobre el formato más que como una prueba de él. Considera la verificación de firma un filtro rápido y económico. La frontera de seguridad llega dos secciones más adelante.

¿Dónde deben residir los límites de tamaño de subida?

Los límites de tamaño corresponden primero al proxy, en segundo lugar al framework y, por último, al código de la aplicación, porque una comprobación que se ejecuta después de haber almacenado el cuerpo en buffer rechaza la subida solo cuando el payload completo ya te ha costado memoria y ancho de banda. En nginx, client_max_body_size tiene un valor por defecto de 1 MB y responde a las peticiones excesivas con un 413 antes de que tu proceso las vea:

client_max_body_size 5m;

La capa del framework es Multer (2.x), donde limits.fileSize tiene por defecto Infinity, así que un límite sin establecer equivale a no tener límite:

const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024, files: 1 },
});

Cuando se alcanza el tope, Multer lanza un error LIMIT_FILE_SIZE. En Fastify, @fastify/multipart adopta la postura opuesta y establece fileSize en 1 MiB por defecto, de modo que el comportamiento seguro es el predeterminado en lugar de algo que haya que activar. Las repeticiones de sesión de flujos de subida hacen visible el coste de omitir la capa del proxy como un defecto de UX: la barra de progreso llega al 100 por cien y entonces aparece el rechazo, porque el payload completo tenía que llegar antes de que la comprobación a nivel de aplicación pudiera ejecutarse.

Descarta el nombre de archivo del cliente

Nunca dejes que el nombre de archivo del cliente toque tu sistema de archivos. Genera el tuyo propio a partir de crypto.randomUUID() más la extensión que haya determinado tu propia validación:

const storedName = `${crypto.randomUUID()}.jpg`;

El razonamiento, desde las secuencias de traversal hasta por qué path.normalize no es una defensa, se trata en Preventing Path Traversal Attacks in Node.js; el enfoque del nombre generado hace inalcanzable toda esa clase de ataques.

Recodifica la imagen; no almacenes los bytes originales

La recodificación es el control que vuelve irrelevante la falsificación de firmas. sharp (0.35.x) decodifica los píxeles y escribe un archivo completamente nuevo, de modo que lo que se hubiera añadido al principio, al final u oculto dentro del original nunca sobrevive. También cierra una fuga de privacidad: las fotos de móvil llevan habitualmente coordenadas GPS en sus datos EXIF, y almacenar los bytes originales significa republicar la ubicación de tu usuario. La documentación de salida de sharp detalla el comportamiento por defecto: nada de los metadatos de entrada llega a la salida a menos que lo pidas explícitamente con keepExif() o withMetadata(). La marca de orientación se va con todo lo demás, así que aplica la orientación automática primero con .autoOrient() o las fotos de móvil saldrán de lado:

let clean;
try {
  clean = await sharp(req.file.buffer)
    .autoOrient() // apply EXIF orientation before metadata is stripped
    .jpeg({ quality: 85 })
    .toBuffer();
} catch {
  return res.status(422).send("Not a decodable image"); // decode failure is a rejection
}

Un buffer que pasó la verificación de firma pero no se puede decodificar estaba mintiendo sobre su formato. Eso es la comprobación funcionando.

Sirve lo que validaste, no lo que recibiste

Al servir imágenes almacenadas, establece el Content-Type a partir de tu registro de validación, nunca a partir de nada que haya enviado el cliente; añade X-Content-Type-Options: nosniff para que el navegador no pueda cuestionarlo; y almacena los archivos fuera del web root para que nada de lo subido sea nunca ejecutable ni renderizable de forma directa:

app.get("/images/:id", async (req, res) => {
  const record = await getImageRecord(req.params.id); // contentType saved at validation time
  if (!record) return res.sendStatus(404);
  res.setHeader("Content-Type", record.contentType);
  res.setHeader("X-Content-Type-Options", "nosniff");
  res.sendFile(record.storedName, { root: UPLOAD_DIR }); // UPLOAD_DIR is outside the web root
});

Algunos temas adyacentes, cada uno en una línea:

  • El análisis antivirus (ClamAV o un equivalente en la nube) importa cuando aceptas documentos arbitrarios, no imágenes recodificadas.
  • Si alguna vez aceptas archivos comprimidos, comprueba el tamaño descomprimido antes de extraerlos; las zip bombs son archivos pequeños que se expanden enormemente.
  • SVG es un documento XML que puede contener scripts, así que excluyelo por completo de los endpoints de imágenes.
  • Las arquitecturas de URL prefirmadas suben directamente al almacenamiento de objetos, lo que traslada toda esta canalización a un paso de procesamiento posterior a la subida en lugar de eliminarla.

Conclusión

Los cinco controles forman una única canalización: rechaza por tamaño en el proxy, verifica la firma en memoria contra la allowlist de este endpoint, recodifica con sharp, almacena bajo un nombre que hayas generado tú y sirve con cabeceras que controles.

ControlDónde se ejecutaQué detiene
Límite de tamañoPrimero el proxy, luego limits de Multer, por último el código de la appCuerpos excesivos que consumen memoria y ancho de banda
Verificación de firmaEn memoria, antes de que nada llegue al discoBytes que no coinciden con la allowlist de este endpoint
Recodificación con sharpDespués de la validación, antes del almacenamientoFirmas falsificadas, payloads ocultos, datos GPS en EXIF
Nombre de archivo generadoEn el momento del almacenamientoPath traversal a través del nombre de archivo del cliente
Cabeceras de entrega validadasEn cada lecturaMIME sniffing y ejecución desde el web root

La verificación de firma filtra de forma económica; la recodificación es la frontera que se mantiene incluso cuando la firma fue falsificada. Empieza revisando tu propia configuración de Multer: si limits.fileSize no está establecido, ese endpoint acepta actualmente archivos de tamaño ilimitado.

Preguntas frecuentes

¿Sustituye el paquete npm file-type a una verificación de firma escrita a mano?

Sustituye la comparación de bytes, no el modelo de seguridad. file-type lee los mismos números mágicos, y su README es contundente sobre lo que eso te aporta: una coincidencia es una pista, y no determina ni si el archivo es realmente de ese tipo ni si está bien formado. Sigues necesitando una allowlist específica del endpoint por encima, porque reconoce cientos de formatos, y la recodificación sigue siendo la verdadera frontera. Ten en cuenta que el paquete es solo ESM, así que los proyectos CommonJS necesitan un import dinámico o el workaround de load-esm.

¿El límite fileSize de Multer impide que el cliente envíe el resto del archivo?

No de forma fiable. Cuando se alcanza limits.fileSize, Multer deja de almacenar en buffer y lanza un error LIMIT_FILE_SIZE, lo que protege tu proceso de un uso de memoria sin límite, pero nada en la documentación garantiza que se cancele la transferencia de red, y el error puede aparecer solo después de que todos los bytes hayan llegado. Un tope a nivel de proxy como client_max_body_size de nginx es lo que realmente protege el ancho de banda, y por eso el límite corresponde primero al proxy.

¿Cómo se validan las subidas cuando los clientes usan URLs prefirmadas para subir directamente al almacenamiento de objetos?

La validación se traslada a un paso posterior a la subida en lugar de desaparecer. El cliente sube a un bucket o prefijo de cuarentena desde el que nada se sirve; después, un worker en segundo plano o una función activada por el almacenamiento descarga el objeto, ejecuta la misma verificación de firma y la recodificación con sharp, y escribe la salida limpia en la ubicación pública bajo un nombre generado. Los objetos que no pasan la validación se eliminan, y la ubicación de cuarentena nunca se expone a los navegadores.

¿La recodificación con sharp cambia los colores de la imagen?

Puede hacerlo en imágenes de gamut amplio. Sin intervención, sharp te da una salida sRGB sin perfil adjunto, ya que la eliminación de metadatos se lleva consigo el perfil ICC incrustado, de modo que las fotos creadas en Display P3 o Adobe RGB pueden desplazarse ligeramente. Para preservar el color sin reintroducir datos EXIF, llama a keepIccProfile() antes de codificar. keepMetadata() también conserva el perfil, pero devuelve todo lo demás, incluidas las coordenadas GPS de las que la eliminación protegía a tus usuarios.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.