API segura de subida de imágenes con Node.js, Express y Multer
Un archivo subido solo debe poder descargarse después de que sus bytes superen tus comprobaciones. Este ejemplo recibe un JPEG o PNG, lo mantiene en cuarentena mientras ClamAV lo escanea, lo decodifica con Sharp y luego permite descargar el archivo original mediante una ruta de Express. Los archivos rechazados quedan fuera de esa ruta.
El resultado es una API local de subida de imágenes para desarrolladores que prueban un pipeline de
subida del lado del servidor. Escucha solo en 127.0.0.1, acepta archivos de hasta
5 MiB y conserva los bytes aceptados, incluidos los metadatos de la imagen. No tiene cuentas de usuario
ni autorización por archivo. Mantenla local hasta que tu aplicación proporcione esos controles.
Configura el entorno de Node.js
Usa Linux con Node.js 26.8.1, Yarn 4.12.0 mediante Corepack, Docker y cURL. Estas son las versiones y la plataforma utilizadas aquí. Reserva 4 GiB de memoria para el contenedor de ClamAV, además de la memoria que necesita Node.js. El daemon de Docker debe ejecutarse en esta máquina para poder montar el directorio de cuarentena.
Crea un proyecto nuevo. La cadena && se detiene si el directorio ya existe
o si falla algún paso de configuración; elige otro nombre de proyecto en lugar de eliminar un
directorio existente. Conserva el archivo de bloqueo generado.
mkdir image-upload-api &&
cd image-upload-api &&
printf '%s\n' '{"name":"image-upload-api","private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
touch yarn.lock &&
corepack yarn config set nodeLinker node-modules &&
corepack yarn add --exact express@5.2.1 multer@2.4.0 sharp@0.35.4 express-rate-limit@8.7.0 &&
corepack yarn add --dev --exact @types/express@5.0.6 @types/multer@2.2.0 @types/node@26.6.2
Usa Multer 2.4.0 o una versión posterior con el parche. Las versiones de 2.2.0 a 2.3.0 pueden dejar archivos huérfanos cuando un cliente se desconecta antes de que un callback de almacenamiento asíncrono asigne la ruta. El aviso del responsable del mantenimiento identifica la versión 2.4.0 como la solución. El manejo de errores de la aplicación por sí solo no corrige esa condición de carrera de la biblioteca.
Escaneo de virus en los archivos subidos
Desde el nuevo directorio del proyecto, inicia un escáner privado. Esta imagen fijada en ClamAV 1.5.4
incluye una base de datos de firmas. El contenedor no tiene acceso a la red ni puertos publicados;
solo se monta la carpeta de cuarentena, en modo de solo lectura. La aplicación invoca
clamdscan dentro del contenedor mediante Docker.
mkdir -m 700 quarantine accepted &&
docker run --detach --rm --name image-upload-clamav \
--memory 4g --network none --env CLAMAV_NO_FRESHCLAMD=true \
--mount "type=bind,source=$PWD/quarantine,target=/scan,readonly" \
clamav/clamav@sha256:0e31ce089574268aefa0b543767d66b70240ab51ed49eec53e07f18d5629d817
Espera a que el daemon cargue su base de datos antes de iniciar la API:
docker exec image-upload-clamav clamdscan --ping 120:1 &&
docker exec image-upload-clamav clamdscan --version
La imagen fijada informó que usaba la base de datos 28129, con fecha del 20 de septiembre de 2026 y cuatro días de antigüedad al realizar las pruebas. Desactivar FreshClam convierte este ejemplo en una demostración sin conexión, no en un servicio de escaneo actualizado continuamente. Un resultado limpio significa que estas firmas no detectaron malware; no certifica que un archivo sea inofensivo. Para un servicio desplegado, mantén las firmas actualizadas y supervisa el estado del escáner. La guía oficial de Docker explica las actualizaciones de la base de datos y los requisitos de memoria.
Crea la estructura básica del endpoint de la API
Guarda el siguiente programa completo como app.ts en el directorio del
proyecto. Node ejecuta este archivo TypeScript directamente. Inícialo desde ese mismo directorio
para que las rutas del host coincidan con el montaje del escáner.
El escáner solo acepta un resultado satisfactorio que indique explícitamente que el archivo examinado
está limpio. La subida se rechaza si falta el contenedor, se produce un error del daemon, se agota el
tiempo de espera o se recibe una respuesta inesperada. --fdpass permite que
clamdscan abra el archivo privado y pase su descriptor al daemon a través de su
socket Unix; consulta la documentación de escaneo de ClamAV.
import { execFile } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { link, mkdir, rm, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import express, { type ErrorRequestHandler } from 'express'
import { rateLimit } from 'express-rate-limit'
import multer from 'multer'
import sharp from 'sharp'
process.umask(0o077)
const quarantine = resolve('quarantine')
const accepted = resolve('accepted')
const container = process.env.CLAMAV_CONTAINER ?? 'image-upload-clamav'
const port = Number(process.env.PORT ?? 3000)
const app = express()
let activeUploads = 0
class UploadError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
function scanFile(filename: string): Promise<void> {
const path = `/scan/${filename}`
return new Promise((resolveScan, reject) => {
execFile('docker', ['exec', container, 'clamdscan', '--fdpass', '--no-summary', path],
{ timeout: 30_000, maxBuffer: 64 * 1024 }, (error, stdout) => {
const result = stdout.trim()
if (!error && result === `${path}: OK`) return resolveScan()
if (error?.code === 1 && result.startsWith(`${path}: `) && result.endsWith(' FOUND')) {
return reject(new UploadError(422, 'Malware detected.'))
}
reject(new UploadError(503, 'Scanner unavailable or scan inconclusive.'))
})
})
}
const receive = multer({
storage: multer.diskStorage({
destination: quarantine,
filename(_req, _file, callback) {
randomBytes(16, (error, bytes) => {
if (error) return callback(error, '')
callback(null, bytes.toString('hex'))
})
},
}),
limits: { fileSize: 5 * 1024 * 1024, files: 1, fields: 0, parts: 2 },
fileFilter(_req, file, callback) {
if (file.mimetype !== 'image/jpeg' && file.mimetype !== 'image/png') {
return callback(new UploadError(415, 'Send a JPEG or PNG image.'))
}
callback(null, true)
},
}).single('image')
const uploadLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: 'draft-8',
legacyHeaders: false,
message: { error: 'Upload limit reached. Try again after 15 minutes.' },
})
app.disable('x-powered-by')
app.use((_req, res, next) => {
res.set({ 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-store' })
next()
})
app.post('/upload', uploadLimiter, async (req, res) => {
if (activeUploads >= 2) throw new UploadError(503, 'Two uploads are already processing.')
activeUploads += 1
// An absolute deadline also covers a client that keeps sending tiny chunks.
const deadline = setTimeout(() => res.destroy(), 60_000)
let publishedPath: string | undefined
let committed = false
try {
await new Promise<void>((done, reject) => {
receive(req, res, (error: unknown) => error ? reject(error) : done())
})
if (!req.file) throw new UploadError(400, 'Use the multipart file field named image.')
await scanFile(req.file.filename)
const decoder = sharp(req.file.path, { limitInputPixels: 12_000_000, failOn: 'warning' })
const metadata = await decoder.metadata().catch(() => {
throw new UploadError(415, 'Image headers are invalid or exceed 12 megapixels.')
})
if (metadata.format !== 'jpeg' && metadata.format !== 'png') {
throw new UploadError(415, 'Only JPEG and PNG files are accepted.')
}
const mime = metadata.format === 'jpeg' ? 'image/jpeg' : 'image/png'
if (mime !== req.file.mimetype) throw new UploadError(415, 'Image bytes and MIME type differ.')
await decoder.raw().toBuffer().catch(() => {
throw new UploadError(415, 'Image pixels could not be decoded.')
})
if (req.aborted || res.destroyed) return
const filename = `${req.file.filename}.${metadata.format === 'jpeg' ? 'jpg' : 'png'}`
const destination = resolve(accepted, filename)
// A hard link publishes the complete file atomically and refuses an existing name.
await link(req.file.path, destination)
publishedPath = destination
if (req.aborted || res.destroyed) return
await rm(req.file.path)
res.status(201).json({ filename, size: req.file.size, url: `/images/${filename}` })
committed = true
} finally {
clearTimeout(deadline)
try {
// Multer may already have removed the file and cleared its path after a later part fails.
if (req.file?.path) await rm(req.file.path, { force: true })
if (publishedPath && !committed) await rm(publishedPath, { force: true })
} finally {
activeUploads -= 1
}
}
})
app.get('/images/:filename', (req, res, next) => {
const { filename } = req.params
if (!/^[a-f0-9]{32}\.(jpg|png)$/.test(filename)) {
throw new UploadError(404, 'Image not found.')
}
res.download(resolve(accepted, filename), filename, (error) => {
if (!error) return
if (res.headersSent) return next(error)
next(new UploadError(404, 'Image not found.'))
})
})
const handleError: ErrorRequestHandler = (error: unknown, _req, res, next) => {
if (res.headersSent) return next(error)
if (res.destroyed) return
const status = error instanceof UploadError ? error.status
: error instanceof multer.MulterError ? (error.code === 'LIMIT_FILE_SIZE' ? 413 : 400)
: 500
const message = error instanceof UploadError ? error.message
: status === 413 ? 'File exceeds 5 MiB.' : 'Upload could not be processed.'
console.error('Request failed', { status })
res.status(status).json({ error: message })
}
app.use(handleError)
async function main(): Promise<void> {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('PORT must be an integer from 1 to 65535.')
}
await mkdir(accepted, { recursive: true, mode: 0o700 })
const probe = `probe-${randomBytes(16).toString('hex')}`
await writeFile(resolve(quarantine, probe), 'Scanner readiness check\n', { flag: 'wx' })
try {
await scanFile(probe)
} finally {
await rm(resolve(quarantine, probe), { force: true })
}
app.listen(port, '127.0.0.1', (error?: Error) => {
if (error) {
console.error('Cannot bind API port; choose an unused PORT.')
process.exitCode = 1
return
}
console.log(`Ready at http://127.0.0.1:${port}`)
})
}
main().catch((error: unknown) => {
console.error(error instanceof UploadError ? error.message
: 'Startup failed. Check PORT, directories, and the scanner mount.')
process.exitCode = 1
})
Comprueba los bytes antes de publicarlos
Multer gestiona la estructura multipart y el límite de 5 MiB. Su filtro MIME es una comprobación
preliminar de una etiqueta proporcionada por el cliente. Sharp compara después el formato detectado
con esa etiqueta y decodifica los píxeles; un archivo de texto llamado
photo.png no supera la validación. El nombre de archivo original nunca se
convierte en una ruta de almacenamiento. Las descargas reciben una extensión derivada del formato
detectado.
metadata() de Sharp lee los encabezados sin
decodificar los datos de píxeles, por lo que el ejemplo también llama a
raw().toBuffer(). Sharp decodifica la imagen predeterminada; esto no valida todos los
fotogramas de animación de un archivo APNG. El búfer decodificado se descarta, por lo que el archivo
almacenado sigue siendo idéntico, byte por byte, al archivo subido. El
límite de 12 millones de píxeles y la decodificación estricta reducen
la exposición de los recursos. No constituyen un entorno aislado para el decodificador nativo.
Mantén Sharp y sus bibliotecas nativas actualizados con los parches. GIF, SVG y otros formatos quedan
fuera de las entradas aceptadas en este ejemplo.
Se pueden recibir, escanear o decodificar dos archivos subidos a la vez. Las subidas adicionales
reciben 503; el plazo de 60 segundos cierra la conexión del cliente;
el procesamiento nativo que ya está en curso termina antes de liberar su cupo. El limitador por IP
permite diez intentos cada 15 minutos, incluidos los rechazados. Sus contadores en memoria se
reinician con el proceso y no se comparten entre servidores. Ninguno de los límites proporciona
una cuota por cuenta ni limita cuántos archivos aceptados se acumulan con el tiempo.
Inicia la API e interpreta los fallos
Inicia el servidor en primer plano cuando el escáner esté listo:
node app.ts
Espera a que aparezca Ready at http://127.0.0.1:3000. Durante el inicio se realiza un escaneo real
a través del directorio de cuarentena montado antes de abrir el puerto. Si el puerto 3000 está
ocupado, elige otro con PORT=3007 node app.ts y usa ese puerto en los comandos de cURL.
Express 5 pasa los errores de enlace al
callback de app.listen; en ese caso, este programa
termina con un estado de error en lugar de imprimir una URL lista para usar.
| Estado | Significado |
|---|---|
201 | Archivo escaneado y decodificado; los bytes originales están disponibles en la URL devuelta. |
400 | Falta el archivo, hay un campo inesperado o se ha alcanzado un límite multipart de Multer distinto del tamaño de archivo. |
413 | El archivo supera los 5 MiB. |
415 | Tipo MIME no admitido, bytes que no coinciden, imagen no válida o incumplimiento del límite de píxeles. |
422 | ClamAV detectó malware. |
429 | Demasiados intentos de subida desde esta IP. |
503 | Fallo del escáner o ya hay dos subidas en procesamiento. |
500 | Fallo inesperado de análisis, del sistema de archivos o del servidor. |
En las solicitudes rechazadas de forma habitual, el archivo se elimina de la cuarentena. Multer con el parche también gestiona las escrituras abortadas, incluido el callback asíncrono del nombre de archivo. Un fallo del proceso o un cierre forzado aún pueden dejar archivos en cuarentena; inspecciónalos y elimínalos solo mientras la API esté detenida. Una vez que el servidor guarda de forma definitiva un archivo aceptado, este permanece almacenado aunque el cliente pierda la respuesta.
Prueba la API con cURL
Abre otra terminal en el directorio del proyecto. Crea un PNG pequeño y válido sin necesidad de
descargar una imagen de muestra. Este código se niega a sobrescribir un
sample.png existente:
node --input-type=module -e '
import { writeFile } from "node:fs/promises";
import sharp from "sharp";
const image = await sharp({ create: { width: 2, height: 2, channels: 3, background: "red" } }).png().toBuffer();
await writeFile("sample.png", image, { flag: "wx" });
'
Súbelo y luego descarga el archivo desde la URL de la respuesta JSON. La opción noclobber del shell
hace que este bloque rechace los archivos de resultados existentes. Usa nombres de archivo nuevos
para otra ejecución. cmp no imprime nada y termina correctamente cuando
los bytes descargados coinciden con el original.
(
set -euC
curl --fail-with-body --silent --show-error \
-F 'image=@sample.png;type=image/png' http://127.0.0.1:3000/upload > upload.json
image_url=$(node --input-type=module -e '
import { readFile } from "node:fs/promises";
const result = JSON.parse(await readFile("upload.json", "utf8"));
if (!/^\/images\/[a-f0-9]{32}\.(jpg|png)$/.test(result.url)) throw new Error("Invalid upload response");
console.log(result.url);
')
curl --fail --silent --show-error "http://127.0.0.1:3000$image_url" > downloaded.png
cmp sample.png downloaded.png
)
Para una prueba sencilla de rechazo, envía el manifiesto del proyecto indicando que es un PNG:
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' \
-F 'image=@package.json;filename=photo.png;type=image/png' http://127.0.0.1:3000/upload
El resultado esperado es 415, ningún archivo aceptado nuevo y un
directorio de cuarentena vacío después de la solicitud. Si detienes el escáner con
docker stop image-upload-clamav mientras la API está en ejecución, una imagen válida recibe en su
lugar 503. Reinícialo con el comando docker run
anterior, deja los directorios existentes del proyecto en su lugar y espera a que esté listo antes
de volver a intentarlo.
Decide qué conservar y quién puede descargarlo
Los dos directorios se encuentran fuera de cualquier raíz web estática, en el mismo sistema de archivos para que la publicación pueda usar un enlace físico. Los archivos nuevos son privados para la cuenta del sistema operativo que ejecuta Node. Los archivos aceptados tienen nombres aleatorios y nunca se sobrescriben; volver a subir la misma imagen crea otro archivo. Detén la API con Ctrl+C y detén su escáner al terminar. Ambos directorios permanecen en el disco para que puedas inspeccionarlos o eliminarlos de forma deliberada.
Los registros de errores contienen códigos de estado sin nombres de archivo del cliente ni salida
del escáner. La ruta de descarga sirve archivos adjuntos con nosniff y
no-store. No elimina EXIF, datos de ubicación, bytes al final del archivo
ni otro contenido incrustado. Un escaneo y una decodificación satisfactorios no equivalen a una
sanitización: son afirmaciones de menor alcance. Si necesitas una imagen pública normalizada, añade
un paso de recodificación independiente y verifica su política de metadatos y formatos antes de
exponer su resultado.
Antes de conectar este pipeline a una aplicación pública o a un almacén de objetos privado, añade autenticación, autorización por archivo, cuotas de almacenamiento y una política de retención. Las URL aleatorias y CORS no proporcionan comprobaciones de propiedad. El adaptador de comandos de Docker es práctico para una demostración local, pero da al proceso de Node acceso al daemon de Docker; usa una integración específica con el escáner que tenga privilegios más restringidos en un servicio desplegado. Este tutorial no configura ni verifica una ruta de almacenamiento en la nube.
