Implementing server-side malware scanning with ClamAV in Node.js
In this DevTip, we will explore how to implement server-side malware scanning using ClamAV in a Node.js application. By integrating ClamAV, you can scan uploaded files for malware before processing or storing them, significantly enhancing your web application's security.
What is ClamAV?
ClamAV is an open-source antivirus engine designed to detect trojans, viruses, malware, and other malicious threats. It is widely used for mail gateway scanning and can be integrated into various applications for file scanning.
Why implement server-side malware scanning?
Implementing server-side malware scanning is crucial for:
- Protecting your server from malicious files
- Preventing the spread of malware to other users
- Maintaining the integrity of your application
- Complying with security standards and regulations
By scanning files on the server, you add an essential layer of security to your web application.
Setting up ClamAV on your server
Before integrating ClamAV with Node.js, install it on your server. Use the following commands on 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
Wait for the initial virus database download to finish before starting the scanner. Stop the
automatic updater before running freshclam manually so they do not compete for its lock.
The examples use Ubuntu’s /var/run/clamav/clamd.ctl socket. Check the LocalSocket setting in
/etc/clamav/clamd.conf and give your application user permission to connect to it. Configure the
daemon’s limits before accepting uploads:
StreamMaxLength 26M
MaxFileSize 25M
MaxScanSize 100M
AlertExceedsMax yes
Restart clamav-daemon after changing its configuration. AlertExceedsMax makes supported scan
limit violations visible as Heuristics.Limits.Exceeded detections. Reject those as inconclusive;
a file that could not be fully scanned must never be reported as clean. See the
ClamAV scanning documentation for limits and
other reasons a scan might be incomplete.
Integrating ClamAV with Node.js
These examples use clamscan@2.4.0 and CommonJS files (.cjs):
npm install clamscan@2.4.0 express@5 multer@2
Save this wrapper as ClamAVScanner.cjs. Version 2.4.0 supports scanFile and scanStream, but
has no scanBuffer method. This wrapper sends both files and buffers through scanStream so
the daemon does not need filesystem access to the application’s private upload directory:
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
Scanning uploaded files with ClamAV
Integrate the scanner into an Express.js application with proper error handling:
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
})
Best practices and performance tips
-
Update ClamAV Regularly: Keep the
clamav-freshclamservice running and monitor database freshness. Do not schedule a second updater while that service is active. -
Implement File Size Limits: Align application limits with
StreamMaxLength,MaxFileSize, andMaxScanSizein your daemon configuration. They control different limits, including decompressed archive contents:const upload = multer({ limits: { fileSize: 25 * 1024 * 1024 }, // 25MB }) -
Use Stream Scanning: For large files, implement scanning on streams:
const result = await scanner.scanStream(readStream)Keep the source private and immutable until the scan finishes. Enforce upload limits before scanning; do not forward any bytes to consumers before a conclusive result.
-
Implement Batch Processing: Scan files with bounded concurrency. This sequential example preserves the wrapper’s size and result checks:
const scanFiles = async (files) => { const results = [] for (const file of files) results.push(await scanner.scanFile(file)) return results } -
Keep the Daemon Private: Prefer a local Unix socket. Clamd’s TCP protocol has no built-in authentication; never expose it to untrusted networks.
-
Monitor System Resources: ClamAV can be resource-intensive. Monitor memory usage and respond accordingly:
const os = require('os') const freeMem = os.freemem() / (1024 * 1024) // Free memory in MB if (freeMem < 100) { console.warn('Low memory warning') }
Troubleshooting common issues
-
Socket Connection Issues:
if (error.code === 'ENOENT') { console.error('Socket not found. Check ClamAV daemon status:') console.error('sudo systemctl status clamav-daemon') } -
Database Update Issues:
if (error.message.includes('virus database is empty')) { console.error('ClamAV database is empty. Run: sudo freshclam') } -
Permission Issues:
if (error.code === 'EACCES') { console.error('Permission denied. Check file and socket permissions') }
Error handling best practices
When integrating ClamAV into your application, consider these additional error handling strategies:
-
Handle database update errors:
if (error && error.message.includes('virus database is empty')) { console.error('ClamAV database is not initialized. Please run freshclam') } -
Handle socket connection errors:
if (error && error.code === 'ENOENT') { console.error('ClamAV socket not found. Check if clamd is running') }
Implementing these checks ensures that your scanner is properly initialized and that any configuration issues are promptly addressed.
Conclusion
Implementing server-side malware scanning with ClamAV in Node.js significantly enhances the security of your web applications. By following these best practices, performance optimizations, and robust error handling measures, you can build a resilient file scanning system that protects your server and users from potential threats.
If you're looking for a more comprehensive solution for handling file uploads securely, consider using Transloadit. Transloadit offers robust file processing capabilities, including virus scanning, that can be easily integrated into your applications.
