Verifica archivos subidos por API con números mágicos en Node.js
Un archivo subido con el nombre avatar.png no es necesariamente un PNG.
En este tutorial ejecutarás un endpoint local de Node.js que identifica archivos PNG y JPEG
subidos a partir de sus bytes, rechaza otros tipos detectados y aplica límites de bytes al archivo
y a la solicitud. Una respuesta satisfactoria significa que la firma coincidió con la lista de
formatos permitidos, no que la imagen sea válida o segura.
Por qué no basta con validar la extensión del archivo
El cliente proporciona el nombre del archivo y el Content-Type de la parte del
archivo. Cambiar el nombre de un archivo o declarar image/png no cambia su
contenido. El endpoint que se muestra a continuación ignora deliberadamente ambos al decidir si
el tipo detectado está permitido; nunca usa un nombre de archivo del cliente como ruta de
almacenamiento. La guía de OWASP sobre subidas
explica por qué los tipos MIME del cliente no pueden constituir una barrera de seguridad.
Comprende los números mágicos y las firmas de archivos
Los números mágicos son secuencias de bytes reconocibles que sugieren el formato de un archivo.
Un PNG comienza con 89 50 4E 47 0D 0A 1A 0A; un JPEG comienza con
FF D8 FF. Una biblioteca de detección conoce más detalles del formato que
una breve comprobación de prefijos escrita a mano, pero aun así no decodifica la imagen completa.
La documentación de file-type
describe la detección como un indicio obtenido con el mejor esfuerzo posible, no como una prueba de
validez del archivo. Algunos archivos dañados no producen coincidencias o lanzan una excepción;
otros aún tienen una firma reconocible. No llames al resultado valid ni
lo uses como dictamen sobre la presencia de malware.
Implementa la validación con números mágicos en Node.js
Usa Bash en Linux, cURL, Node.js 24.15.0 o una versión más reciente de la línea 24 LTS, y Corepack con Yarn 4.12.0 disponible. El ejemplo se probó con Node.js 24.15.0 y 26.8.1. Para el despliegue, usa una versión LTS actual con los parches al día, en lugar de considerar la versión mínima probada como una recomendación de actualización de seguridad.
Comienza en un directorio con permisos de escritura. Esto crea un proyecto
magic-upload nuevo sin cambiar el directorio de trabajo de tu shell. Rechaza un
directorio de proyecto existente y se detiene antes de la instalación si falla la creación o el
cambio de directorio. El yarn.lock local establece un proyecto de Yarn
independiente; el enlazador node-modules permite que el comando node,
sin herramientas adicionales, resuelva sus dependencias, incluso dentro de un proyecto padre con
Yarn Plug’n’Play.
(
mkdir magic-upload &&
cd magic-upload &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
touch yarn.lock &&
YARN_NODE_LINKER=node-modules corepack yarn add --exact \
express@4.22.2 file-type@22.0.0 multer@2.4.0
)
Multer 2.4.0 proporciona la opción streamHandler que se usa a continuación e
incluye una corrección de seguridad en la limpieza de subidas.
Mantén al día los parches de las dependencias de subida al adaptar el ejemplo.
Valida subidas con límites de tamaño con Express.js
Guarda el programa completo como magic-upload/server.mts. La
eliminación nativa de tipos de Node ejecuta este archivo
.mts como módulo ES sin un compilador ni un cargador de TypeScript.
No comprueba los tipos del programa ni lee un tsconfig.json del proyecto que
lo contiene.
El primer middleware usa express.raw
para leer el cuerpo completo de la solicitud con un límite de 5 MiB + 16 KiB, antes del análisis
multipart. Esto abarca los delimitadores, las cabeceras de las partes y los bytes finales del
cuerpo, además del contenido del archivo, incluso en solicitudes sin
Content-Length. Después, Multer aplica un límite independiente de 5 MiB al archivo
y acepta solo una parte de archivo llamada file, sin campos de texto.
Su streamHandler entrega el cuerpo ya leído al
analizador multipart en lugar de volver a leer el flujo de la solicitud ya agotado.
import express, { type ErrorRequestHandler, type Request, type Response } from 'express'
import { fileTypeFromBuffer } from 'file-type'
import multer from 'multer'
const app = express()
const maxFileBytes = 5 * 1024 * 1024
const maxRequestBytes = maxFileBytes + 16 * 1024
const allowedTypes = new Set(['image/png', 'image/jpeg'])
async function identifyUpload(request: Request, response: Response): Promise<void> {
if (!request.file || request.file.size === 0) {
response.status(400).json({ error: 'Send one nonempty file in the file field' })
return
}
const type = await fileTypeFromBuffer(request.file.buffer)
if (type === undefined || !allowedTypes.has(type.mime)) {
response.status(415).json({ error: 'Only detected PNG or JPEG files are allowed' })
return
}
response.json({ detectedMime: type.mime, size: request.file.size })
}
app.post(
'/upload',
express.raw({ type: 'multipart/form-data', limit: maxRequestBytes, inflate: false }),
(request, response, next) => {
if (!Buffer.isBuffer(request.body)) {
response.status(415).json({ error: 'Send multipart/form-data' })
return
}
const body = request.body
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: maxFileBytes, files: 1, fields: 0, parts: 1 },
streamHandler: (_request, parser) => parser.end(body),
})
upload.single('file')(request, response, (error: unknown) => {
if (error) return next(error)
void identifyUpload(request, response).catch(() => {
response.status(400).json({ error: 'Unable to identify the file' })
})
})
},
)
const rejectUpload: ErrorRequestHandler = (error: unknown, _request, response, _next) => {
const tooLarge =
(error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') ||
(error instanceof Error && 'type' in error && error.type === 'entity.too.large')
response.status(tooLarge ? 413 : 400).json({
error: tooLarge ? 'Upload exceeds byte limit' : 'Malformed upload',
})
}
app.use(rejectUpload)
const port = Number(process.env.PORT ?? '3000')
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('PORT must be an integer between 0 and 65535')
}
const server = app.listen(port, '127.0.0.1')
server.on('listening', () => {
const address = server.address()
if (address !== null && typeof address !== 'string') {
console.log(`Listening on http://127.0.0.1:${address.port}`)
}
})
server.on('error', () => {
console.error('Could not start the upload server; check PORT and whether it is in use')
process.exitCode = 1
})
Desde el mismo directorio donde ejecutaste la configuración, inicia el servidor en primer plano:
node magic-upload/server.mts
Espera a que aparezca la URL de escucha antes de enviar solicitudes. Si el puerto 3000 está ocupado,
establece PORT en otro puerto disponible al ejecutar el mismo comando
y ajusta la URL de la solicitud para que coincida. Detén el servidor con
Ctrl+C; esto te devuelve a tu shell. El endpoint se vincula a la interfaz
de loopback, mantiene las subidas en memoria y no escribe ningún archivo subido en el disco.
Prueba tu implementación
En una segunda terminal, vuelve al directorio que contiene magic-upload.
Crea un PNG de un píxel con un nombre de archivo engañoso, .bin.
La escritura, que solo permite crear, se niega a sobrescribir un
sample.bin existente; elimina ese archivo de demostración de forma deliberada
si quieres volver a crearlo.
node --input-type=module -e '
import { writeFileSync } from "node:fs"
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQI12P4z8DwHwAFAAH/cpxSZwAAAABJRU5ErkJggg=="
writeFileSync("magic-upload/sample.bin", Buffer.from(png, "base64"), { flag: "wx" })
'
Súbelo con un tipo MIME deliberadamente incorrecto para la parte del archivo:
curl -fsS -F 'file=@magic-upload/sample.bin;type=text/plain' \
http://127.0.0.1:3000/upload
La respuesta se basa en los bytes del archivo recibido, no en .bin ni
en text/plain:
{"detectedMime":"image/png","size":70}
Ahora declara que un texto común es un PNG. Este comando imprime el estado HTTP sin tratar el rechazo esperado como un fallo de cURL:
printf '%s' 'not an image' | curl -sS -o /dev/null -w '%{http_code}\n' \
-F 'file=@-;filename=avatar.png;type=image/png' http://127.0.0.1:3000/upload
La respuesta esperada es 415. Prueba también estos comportamientos al
adaptar el endpoint:
- Un JPEG válido con un nombre de archivo no relacionado devuelve
image/jpegy su cantidad real de bytes. - Un archivo ausente o vacío devuelve
400. Un cuerpo multipart mal formado o un campo inesperado también devuelve400. - Las firmas desconocidas y los formatos reconocidos pero no permitidos, como GIF o PDF, devuelven
415. - El contenido de un archivo de hasta 5 MiB inclusive pasa la comprobación de tamaño; un byte más
devuelve
413. - Un cuerpo de solicitud de hasta 5 MiB + 16 KiB inclusive pasa la comprobación de tamaño de la
solicitud; un byte más devuelve
413, incluso si los bytes adicionales están después del delimitador multipart final. Las comprobaciones de tipo y formulario siguen aplicándose por debajo de cualquiera de los dos límites.
Gestiona los casos límite y los archivos políglotas
Un prefijo de tres bytes FF D8 FF puede identificarse como JPEG sin contener
una imagen completa. Una imagen con daños en una parte posterior de su contenido también puede
pasar. El endpoint informa deliberadamente detectedMime, no una decodificación
correcta ni un dictamen de seguridad. Un archivo políglota puede interpretarse como más de un
formato; buscar secuencias de bytes sospechosas en su interior no es un método fiable de detección.
Después de identificar la firma, aplica un decodificador específico del formato que siga recibiendo mantenimiento, límites de dimensiones y píxeles, y un análisis de seguridad del contenido o una reconstrucción adecuados a tu modelo de amenazas. Ejecuta los analizadores de datos no confiables de forma aislada y con límites de ejecución obligatorios. El ejemplo local no hace nada de esto y no rechaza todas las imágenes mal formadas.
Mantén explícitos los límites de memoria y seguridad
Esta es una demostración para subidas pequeñas, no un pipeline de procesamiento en streaming de
archivos grandes. El cuerpo sin procesar y el búfer del archivo de Multer coexisten, con una
sobrecarga adicional del analizador y de la asignación de memoria. Un límite de tamaño de archivo
no limita la memoria total del proceso, las subidas simultáneas ni el tiempo de detección. Los
archivos grandes necesitan una ruta y una política de almacenamiento diferentes; ni un prefijo
de longitud fija ni una coincidencia de firma satisfactoria demuestran que se haya comprobado el
resto del archivo subido. Este tutorial usa Node.js en todo momento; no requiere Python ni
libmagic.
Combina la validación con números mágicos con otros controles
Antes de exponer un endpoint de subida, añade autenticación, autorización, límites de frecuencia
y concurrencia, y plazos máximos para las solicitudes en el punto adecuado de la aplicación o del
proxy. Mantén las dependencias con los parches al día. Si guardas las subidas de forma persistente,
genera nombres de almacenamiento, pon los archivos en cuarentena antes de procesarlos y evita
servir contenido activo desde el origen de tu aplicación. Estos son controles independientes, no
propiedades de file-type; consulta la
lista completa de controles de OWASP para subidas.
Soluciona problemas comunes
- No aparece la URL de escucha: revisa el diagnóstico de inicio,
PORTy si la dirección está ocupada. No envíes una subida de prueba a un servicio ajeno que ya esté usando ese puerto. - No se encuentra un paquete: completa la instalación dentro de
magic-uploady usa el nombre de archivo documentado.mts. No reemplaces las importaciones ESM porrequire()de CommonJS. - Respuesta inesperada
400: envía exactamente un archivo no vacío llamadofiley ningún campo de formulario adicional. Las excepciones de identificación también se notifican sin exponer su error interno. - Respuesta inesperada
413: revisa el cuerpo multipart completo, además del tamaño del archivo. Las cabeceras de las partes y los delimitadores consumen los 16 KiB adicionales permitidos para la solicitud.
Mantén la distinción entre «identificado» y «validado» cuando conectes este endpoint al almacenamiento o al procesamiento. Para un flujo de trabajo de subidas gestionado, explora nuestro servicio de subida de archivos; la política sobre lo que acepta tu aplicación sigue siendo tu responsabilidad.
