Escaneo de malware del lado del servidor con ClamAV en Node.js
En este DevTip exploraremos cómo implementar el escaneo de malware del lado del servidor con ClamAV en una aplicación Node.js. Al integrar ClamAV, puedes analizar los archivos subidos en busca de malware antes de procesarlos o almacenarlos, lo que mejora notablemente la seguridad de tu aplicación web.
¿Qué es ClamAV?
ClamAV es un motor antivirus de código abierto diseñado para detectar troyanos, virus, malware y otras amenazas maliciosas. Se usa ampliamente para el escaneo en pasarelas de correo y puede integrarse en diversas aplicaciones para analizar archivos.
¿Por qué implementar el escaneo de malware del lado del servidor?
Implementar el escaneo de malware del lado del servidor es fundamental para:
- Proteger tu servidor de archivos maliciosos
- Evitar la propagación de malware a otros usuarios
- Mantener la integridad de tu aplicación
- Cumplir con los estándares y las normativas de seguridad
Al analizar los archivos en el servidor, añades una capa de seguridad esencial a tu aplicación web.
Configurar ClamAV en tu servidor
Antes de integrar ClamAV con Node.js, instálalo en tu servidor. Usa los siguientes comandos en Ubuntu:
sudo apt-get update
sudo apt-get install clamav clamav-daemon
sudo systemctl stop clamav-freshclam
sudo freshclam
sudo systemctl start clamav-freshclam
sudo systemctl start clamav-daemon
sudo systemctl enable clamav-daemon
Espera a que termine la descarga inicial de la base de datos de virus antes de iniciar el escáner.
Detén el actualizador automático antes de ejecutar freshclam manualmente para que no compitan por su bloqueo.
Los ejemplos usan el socket /var/run/clamav/clamd.ctl de Ubuntu. Revisa la opción LocalSocket en
/etc/clamav/clamd.conf y otorga al usuario de tu aplicación permiso para conectarse a él. Configura los
límites del daemon antes de aceptar subidas:
StreamMaxLength 26M
MaxFileSize 25M
MaxScanSize 100M
AlertExceedsMax yes
Reinicia clamav-daemon después de cambiar su configuración. AlertExceedsMax hace visibles las
infracciones de los límites de escaneo compatibles como detecciones Heuristics.Limits.Exceeded. Recházalas por no ser
concluyentes; un archivo que no se pudo analizar por completo nunca debe reportarse como limpio.
Consulta la documentación de escaneo de ClamAV para conocer los límites y
otras razones por las que un escaneo podría quedar incompleto.
Integrar ClamAV con Node.js
Estos ejemplos usan clamscan@2.4.0 y archivos CommonJS (.cjs):
npm install clamscan@2.4.0 express@5 multer@2
Guarda este wrapper como ClamAVScanner.cjs. La versión 2.4.0 admite scanFile y scanStream, pero
no tiene un método scanBuffer. Este wrapper envía tanto archivos como búferes a través de scanStream, de
modo que el daemon no necesita acceso en el sistema de archivos al directorio privado de subidas de
la aplicación:
const ClamScan = require('clamscan')
const { createReadStream } = require('node:fs')
const { stat } = require('node:fs/promises')
const { Readable } = require('node:stream')
const MAX_FILE_BYTES = 25 * 1024 * 1024
class ClamAVScanner {
constructor() {
this.clamscan = null
this.isInitialized = false
}
async initialize() {
try {
this.clamscan = await new ClamScan().init({
removeInfected: false,
quarantineInfected: false,
scanLog: null,
debugMode: false,
fileList: null,
scanRecursively: true,
clamscan: {
path: '/usr/bin/clamscan',
db: null,
scanArchives: true,
active: false,
},
preference: 'clamdscan',
clamdscan: {
socket: '/var/run/clamav/clamd.ctl',
timeout: 60000,
localFallback: false,
path: '/usr/bin/clamdscan',
configFile: null,
multiscan: true,
reloadDb: false,
},
})
this.isInitialized = true
} catch (err) {
if (err.message.includes('virus database is empty')) {
console.error('ClamAV database is not initialized. Please run freshclam')
} else if (err.code === 'ENOENT') {
console.error('ClamAV socket not found. Check if clamd is running')
} else {
console.error('ClamAV initialization failed')
}
throw err
}
}
async scanFile(filePath) {
const info = await stat(filePath)
if (!info.isFile() || info.size === 0 || info.size > MAX_FILE_BYTES) {
throw new Error('File is empty, invalid, or exceeds the scan limit')
}
return this.scanStream(createReadStream(filePath))
}
async scanBuffer(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length === 0 || buffer.length > MAX_FILE_BYTES) {
throw new Error('Buffer is empty, invalid, or exceeds the scan limit')
}
return this.scanStream(Readable.from([buffer]))
}
async scanStream(readStream) {
if (!this.isInitialized) {
readStream.destroy()
throw new Error('ClamAV scanner not initialized')
}
let timer
// clamscan 2.4 attaches input listeners only after its socket has connected.
const streamError = new Promise((_, reject) => readStream.once('error', reject))
try {
// The package's socket timeout alone does not settle every scan failure path.
const result = await Promise.race([
streamError,
this.clamscan.scanStream(readStream),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('Scan timed out')), 60000)
}),
])
if (
!result ||
result.timeout === true ||
(result.isInfected !== true && result.isInfected !== false) ||
!Array.isArray(result.viruses)
) {
throw new Error('Scan result is inconclusive')
}
const { isInfected, viruses } = result
if (viruses.some((name) => name.startsWith('Heuristics.Limits.Exceeded'))) {
throw new Error('Scan limit exceeded; result is inconclusive')
}
return { isInfected, viruses }
} finally {
clearTimeout(timer)
readStream.destroy()
}
}
}
module.exports = ClamAVScanner
Escanear archivos subidos con ClamAV
Integra el escáner en una aplicación Express.js con un manejo de errores adecuado:
const express = require('express')
const multer = require('multer')
const { mkdir, rm } = require('node:fs/promises')
const ClamAVScanner = require('./ClamAVScanner.cjs')
const app = express()
const upload = multer({
dest: 'uploads/',
limits: {
fileSize: 25 * 1024 * 1024, // 25MB limit
},
})
const scanner = new ClamAVScanner()
let scannerInitialized = false
function diagnosticCode(error) {
// Never log arbitrary messages, paths, or third-party error payloads.
return ['ENOENT', 'EACCES', 'EEXIST', 'ECONNREFUSED', 'ETIMEDOUT'].includes(error?.code)
? error.code
: 'SCAN_FAILED'
}
// Initialize the ClamAV scanner with retry logic
const initializeScanner = async (retries = 3, delay = 5000) => {
for (let i = 0; i < retries; i++) {
try {
await scanner.initialize()
scannerInitialized = true
console.log('ClamAV scanner initialized successfully')
return
} catch (err) {
console.error(`Failed to initialize ClamAV (attempt ${i + 1}/${retries}):`, {
code: diagnosticCode(err),
})
if (i < retries - 1) await new Promise((resolve) => setTimeout(resolve, delay))
}
}
process.exit(1)
}
app.post(
'/upload',
(req, res, next) => {
if (!scannerInitialized) {
return res.status(503).json({
error: 'Scanner not initialized',
message: 'The virus scanner is not ready. Please try again later.',
})
}
next()
},
upload.single('file'),
async (req, res, next) => {
if (!req.file) {
return res.status(400).json({
error: 'No file uploaded',
message: 'Please provide a file to scan.',
})
}
try {
const scanResult = await scanner.scanFile(req.file.path)
if (scanResult.isInfected) {
return res.status(403).json({
error: 'Malware detected',
message: 'The uploaded file contains malware.',
})
}
// A completed scan found no known malware; this does not prove the file is safe.
res.status(200).json({
message: 'Scan completed; no known malware detected',
file: {
name: req.file.originalname,
size: req.file.size,
},
})
} catch (error) {
console.error('File scan could not be completed', { code: diagnosticCode(error) })
res.status(503).json({
error: 'Scan failed',
message: 'An error occurred while scanning the file.',
})
} finally {
// This endpoint only scans; it does not retain uploads after any outcome.
await rm(req.file.path, { force: true }).catch((error) => {
console.error('Temporary file cleanup failed', { code: diagnosticCode(error) })
})
}
},
)
app.use((error, req, res, next) => {
if (res.headersSent) return next(error)
const tooLarge = error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE'
res.status(tooLarge ? 413 : 400).json({ error: 'Upload could not be processed' })
})
async function main() {
await mkdir('uploads', { recursive: true, mode: 0o700 })
await initializeScanner()
app.listen(3000, () => console.log('Server running on port 3000'))
}
main().catch((error) => {
console.error('Server startup failed', { code: diagnosticCode(error) })
process.exitCode = 1
})
Buenas prácticas y consejos de rendimiento
-
Actualiza ClamAV con regularidad: Mantén el servicio
clamav-freshclamen ejecución y vigila la actualidad de la base de datos. No programes un segundo actualizador mientras ese servicio esté activo. -
Implementa límites de tamaño de archivo: Alinea los límites de la aplicación con
StreamMaxLength,MaxFileSizeyMaxScanSizeen la configuración de tu daemon. Controlan límites distintos, incluido el contenido descomprimido de los archivos contenedores:const upload = multer({ limits: { fileSize: 25 * 1024 * 1024 }, // 25MB }) -
Usa el escaneo por streams: Para archivos grandes, implementa el escaneo sobre streams:
const result = await scanner.scanStream(readStream)Mantén la fuente privada e inmutable hasta que termine el escaneo. Aplica los límites de subida antes de escanear; no reenvíes ningún byte a los consumidores antes de un resultado concluyente.
-
Implementa el procesamiento por lotes: Escanea los archivos con una concurrencia acotada. Este ejemplo secuencial conserva las comprobaciones de tamaño y de resultado del wrapper:
const scanFiles = async (files) => { const results = [] for (const file of files) results.push(await scanner.scanFile(file)) return results } -
Mantén el daemon privado: Prefiere un socket Unix local. El protocolo TCP de Clamd no tiene autenticación integrada; nunca lo expongas a redes no confiables.
-
Monitorea los recursos del sistema: ClamAV puede consumir muchos recursos. Monitorea el uso de memoria y actúa en consecuencia:
const os = require('os') const freeMem = os.freemem() / (1024 * 1024) // Free memory in MB if (freeMem < 100) { console.warn('Low memory warning') }
Solución de problemas comunes
-
Problemas de conexión del socket:
if (error.code === 'ENOENT') { console.error('Socket not found. Check ClamAV daemon status:') console.error('sudo systemctl status clamav-daemon') } -
Problemas de actualización de la base de datos:
if (error.message.includes('virus database is empty')) { console.error('ClamAV database is empty. Run: sudo freshclam') } -
Problemas de permisos:
if (error.code === 'EACCES') { console.error('Permission denied. Check file and socket permissions') }
Buenas prácticas de manejo de errores
Al integrar ClamAV en tu aplicación, considera estas estrategias adicionales de manejo de errores:
-
Maneja los errores de actualización de la base de datos:
if (error && error.message.includes('virus database is empty')) { console.error('ClamAV database is not initialized. Please run freshclam') } -
Maneja los errores de conexión del socket:
if (error && error.code === 'ENOENT') { console.error('ClamAV socket not found. Check if clamd is running') }
Implementar estas comprobaciones garantiza que tu escáner se inicialice correctamente y que cualquier problema de configuración se resuelva de inmediato.
Conclusión
Implementar el escaneo de malware del lado del servidor con ClamAV en Node.js mejora notablemente la seguridad de tus aplicaciones web. Si sigues estas buenas prácticas, optimizaciones de rendimiento y medidas robustas de manejo de errores, puedes construir un sistema de escaneo de archivos resiliente que proteja tu servidor y a tus usuarios frente a posibles amenazas.
Si buscas una solución más completa para gestionar la subida de archivos de forma segura, considera usar Transloadit. Transloadit ofrece capacidades robustas de procesamiento de archivos, incluido el escaneo de virus, que se integran fácilmente en tus aplicaciones.
