Implementing server-side malware scanning with ClamAV in Node.js
Keep an upload private until its malware scan finishes. This walkthrough builds a local Node.js endpoint that distinguishes no detection, a detection, and an incomplete scan, then deletes the uploaded copy. You will test it with ordinary text and the harmless EICAR antivirus test string.
Decide what a scan result means
ClamAV’s clamd daemon keeps its antivirus engine loaded and accepts scanning requests over a
socket. The Node.js package clamscan is a client for that daemon. Its scanStream method sends
bytes, so the daemon does not need access to the application’s upload directory.
A completed scan with no detection is not proof that a file is safe. Unsupported formats, encrypted contents, new threats, and format-specific scanning limits still matter. This example rejects reported errors and limit alerts; it does not claim to identify every reason a file might escape inspection. Keep file-type validation and safe downstream processing as separate controls.
Setting up ClamAV on your server
Use Linux, Node.js 24.15.0, Corepack with Yarn 4.12.0, and cURL. The scanning path below was tested
with ClamAV 1.5.4 and clamscan@2.4.0. Before starting the Node application, you need a running
private clamd with an official signature database. Follow the
ClamAV installation guide and
daemon configuration guide if you do not
already have one. Database installation and service management depend on your Linux distribution.
Configure your dedicated test daemon with these limits, then restart it. Keep its existing
DatabaseDirectory and LocalSocket paths. Give only the application user access to the socket
and its parent directory; do not enable a public TCP listener.
StreamMaxLength 26M
MaxFileSize 25M
MaxScanSize 100M
MaxRecursion 16
MaxFiles 1000
AlertExceedsMax yes
These limits do different jobs. StreamMaxLength caps bytes sent over the socket.
MaxFileSize applies to individual files, including unpacked archive members. MaxScanSize
bounds the total scanning work per input, including expanded contents. With AlertExceedsMax,
supported limit violations produce Heuristics.Limits.Exceeded alerts. The wrapper below treats
those as incomplete scans, not malware identifications. See the
versioned configuration reference
for the exact scope of each limit.
Use FreshClam to update official signatures and monitor their age. Do not run a second updater against a database already managed by a FreshClam service. This tutorial uses your daemon’s loaded database; the Node package neither downloads signatures nor changes daemon limits.
Integrating ClamAV with Node.js
From a writable directory, paste this setup block. It creates a new node-clamav project and
refuses to overwrite an existing directory. The explicit Yarn configuration keeps it separate
from an enclosing project. A failed install must be resolved before continuing.
(
set -eu
mkdir -m 700 node-clamav
cd node-clamav
printf '{"private":true,"type":"commonjs","packageManager":"yarn@4.12.0"}\n' > package.json
printf 'nodeLinker: node-modules\n' > .yarnrc.yml
touch yarn.lock
corepack yarn add --exact clamscan@2.4.0 express@5.2.1 multer@2.4.0
)
Save the following three files inside node-clamav. They use explicit CommonJS .cjs extensions
and run directly with Node, without a build step.
First, save ClamAVScanner.cjs. The wrapper uses the package’s
streaming API for both files
and the startup probe. It installs an error listener before connecting, because the input file
can disappear while the client is opening its socket.
const { createReadStream } = require('node:fs')
const { stat } = require('node:fs/promises')
const { Readable } = require('node:stream')
const ClamScan = require('clamscan')
const MAX_FILE_BYTES = 25 * 1024 * 1024
class ClamAVScanner {
async initialize(socket) {
this.clamscan = await new ClamScan().init({
clamscan: { active: false },
preference: 'clamdscan',
clamdscan: { socket, timeout: 10000, localFallback: false },
})
this.isInitialized = true
}
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(input) {
if (!this.isInitialized) {
input.destroy()
throw new Error('ClamAV scanner not initialized')
}
const inputError = new Promise((_, reject) => input.once('error', reject))
try {
const result = await Promise.race([inputError, this.clamscan.scanStream(input)])
if (
!result || result.timeout === true ||
(result.isInfected !== true && result.isInfected !== false) ||
!Array.isArray(result.viruses) ||
!result.viruses.every((name) => typeof name === 'string')
) {
throw new Error('Scan result is inconclusive')
}
const { isInfected, viruses } = result
if (
viruses.some((name) => name.startsWith('Heuristics.Limits.Exceeded')) ||
isInfected !== (viruses.length > 0)
) {
throw new Error('Scan limit or inconsistent result; scan is inconclusive')
}
return { isInfected, viruses }
} finally {
input.destroy()
}
}
}
module.exports = ClamAVScanner
Next, save scan-worker.cjs. Each worker owns one client connection. A missing file path requests
a small startup scan; a real upload uses its private temporary path.
const { parentPort, workerData } = require('node:worker_threads')
const ClamAVScanner = require('./ClamAVScanner.cjs')
async function main() {
const scanner = new ClamAVScanner()
await scanner.initialize(workerData.socket)
const result = workerData.filePath === null
? await scanner.scanBuffer(Buffer.from('scanner startup probe'))
: await scanner.scanFile(workerData.filePath)
parentPort.postMessage({ result })
}
main().catch((error) => {
parentPort.postMessage({ errorCode: typeof error?.code === 'string' ? error.code : 'SCAN_FAILED' })
})
Scanning uploaded files with ClamAV
Save server.cjs. It listens only on loopback and accepts one upload at a time.
Multer writes the single file field to a
fresh private directory. Uploads can be up to 25 MiB. No uploaded filename
becomes a filesystem path, and the application never serves these directories.
The 10-second scan deadline includes client initialization. A rejected promise alone does not
cancel a socket operation, so the application awaits
worker.terminate()
before deleting the temporary input. That stops the Node client; it does not promise to cancel
work already accepted by clamd. The daemon’s own limits still apply.
const { mkdtemp, rm } = require('node:fs/promises')
const { createServer } = require('node:http')
const { tmpdir } = require('node:os')
const { join, resolve } = require('node:path')
const { Worker } = require('node:worker_threads')
const express = require('express')
const multer = require('multer')
const app = express()
let uploadRoot
let busy = false
let stopping = false
let socket
function diagnosticCode(error) {
return ['ENOENT', 'EACCES', 'EEXIST', 'EADDRINUSE', 'ECONNREFUSED', 'ETIMEDOUT'].includes(error?.code)
? error.code
: 'SCAN_FAILED'
}
async function scanFile(filePath) {
const worker = new Worker(join(__dirname, 'scan-worker.cjs'), {
workerData: { socket, filePath },
stdout: true,
stderr: true,
})
// The client can print raw errors even with debugMode disabled.
worker.stdout.resume()
worker.stderr.resume()
let timer
try {
return await new Promise((resolveScan, reject) => {
timer = setTimeout(() => {
reject(Object.assign(new Error('Scan deadline exceeded'), { code: 'ETIMEDOUT' }))
}, 10000)
worker.once('message', (message) => {
if (message.errorCode) {
reject(Object.assign(new Error('Scan failed'), { code: message.errorCode }))
} else {
resolveScan(message.result)
}
})
worker.once('error', reject)
worker.once('exit', () => reject(new Error('Scanner exited without a result')))
})
} finally {
clearTimeout(timer)
await worker.terminate()
}
}
app.post('/upload', async (req, res) => {
if (busy || stopping) return res.status(503).json({ result: 'busy' })
busy = true
let directory
let status = 503
let result = 'inconclusive'
try {
directory = await mkdtemp(join(uploadRoot, 'request-'))
const upload = multer({
dest: directory,
limits: { fileSize: 25 * 1024 * 1024, files: 1, fields: 0, parts: 2 },
}).single('file')
await new Promise((resolveUpload, reject) => {
upload(req, res, (error) => error ? reject(error) : resolveUpload())
})
if (!req.file || req.file.size === 0) {
status = 400
result = 'invalid-upload'
} else {
const scan = await scanFile(req.file.path)
status = scan.isInfected ? 403 : 200
result = scan.isInfected ? 'detected' : 'no-detection'
}
} catch (error) {
if (error instanceof multer.MulterError) {
status = error.code === 'LIMIT_FILE_SIZE' ? 413 : 400
result = 'invalid-upload'
} else {
console.error('File scan could not be completed', { code: diagnosticCode(error) })
}
} finally {
if (directory) {
try {
await rm(directory, { recursive: true, force: true })
} catch (error) {
status = 500
result = 'cleanup-failed'
stopping = true
console.error('Temporary file cleanup failed', { code: diagnosticCode(error) })
}
}
busy = false
}
if (!res.destroyed) res.status(status).json({ result })
})
async function main() {
const port = Number(process.env.PORT ?? '3000')
if (!Number.isInteger(port) || port < 0 || port > 65535 || !process.env.CLAMD_SOCKET) {
throw new Error('Set CLAMD_SOCKET and a valid PORT')
}
socket = resolve(process.env.CLAMD_SOCKET)
const probe = await scanFile(null)
if (probe.isInfected) throw new Error('Startup probe triggered a detection')
uploadRoot = await mkdtemp(join(tmpdir(), 'node-clamav-'))
const server = createServer({ requestTimeout: 15000, connectionsCheckingInterval: 1000 }, app)
await new Promise((resolveListen, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', resolveListen)
})
console.log(`Listening on http://127.0.0.1:${server.address().port}`)
const stop = () => {
if (stopping && !server.listening) return
stopping = true
server.close(() => {
rm(uploadRoot, { recursive: true, force: true }).catch((error) => {
console.error('Temporary file cleanup failed', { code: diagnosticCode(error) })
process.exitCode = 1
})
})
}
process.on('SIGINT', stop)
process.on('SIGTERM', stop)
}
main().catch(async (error) => {
console.error('Server startup failed; check CLAMD_SOCKET, PORT, and daemon status', {
code: diagnosticCode(error),
})
if (uploadRoot) {
await rm(uploadRoot, { recursive: true, force: true }).catch(() => {
console.error('Temporary file cleanup failed')
})
}
process.exitCode = 1
})
From the parent directory, start the server in a terminal, replacing the socket path with your
daemon’s LocalSocket value. For example, some Ubuntu packages use
/var/run/clamav/clamd.ctl. The startup probe must succeed before the ready URL appears. Set
PORT to another port if 3000 is occupied.
(cd node-clamav && CLAMD_SOCKET=/absolute/path/to/clamd.sock node server.cjs)
Send a benign file and the EICAR test string
In a second terminal at the same parent directory, paste this block. It creates new test files without replacing existing files. EICAR is a harmless antivirus test pattern, not live malware; your computer’s antivirus may quarantine it. Do not disable protection to keep it.
(
set -eu
cd node-clamav
node <<'JS'
const { writeFileSync } = require('node:fs')
writeFileSync('hello.txt', 'ordinary upload\n', { flag: 'wx' })
writeFileSync('eicar.txt', 'X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*', { flag: 'wx' })
JS
curl -sS -i --max-time 20 -F 'file=@hello.txt' http://127.0.0.1:3000/upload
curl -sS -i --max-time 20 -F 'file=@eicar.txt' http://127.0.0.1:3000/upload
)
The first request should return HTTP 200 and {"result":"no-detection"}. The EICAR request
should return HTTP 403 and {"result":"detected"}. These commands deliberately omit cURL’s
-f option so you can inspect rejection responses. If you changed PORT, update both URLs.
The files you created stay in your project; only the server’s uploaded copies are deleted.
Interpret failures and check cleanup
| HTTP status | Result | Meaning |
|---|---|---|
| 200 | no-detection | The configured scan returned no known detection. |
| 403 | detected | The scanner returned a detection. |
| 400 | invalid-upload | Missing, empty, or unsupported multipart fields. |
| 413 | invalid-upload | The upload exceeded 25 MiB. |
| 503 | inconclusive | A scan, upload-parser error, or limit alert prevented a result. |
| 503 | busy | Another request is active, or the server is stopping. |
| 500 | cleanup-failed | Deletion failed; the server refuses further uploads. |
Cleanup runs before the JSON response, including on upload rejection and scanning failure. A filesystem failure can still prevent deletion; the server reports it and stops accepting work. Ctrl+C stops new connections, lets active requests finish, and removes the process’s temporary root. A crash or forced kill can leave files behind. This is a local scanning demo with no upload retention, authentication, or production availability guarantee.
Troubleshooting common issues
If startup fails, check that CLAMD_SOCKET names a listening socket and that your user can
traverse its parent directories. ENOENT means a missing path, EACCES a permissions problem,
and EADDRINUSE an occupied HTTP port. Fix the cause and restart; the application does not
silently switch to a different scanner.
If a small text file works but an archive returns inconclusive, inspect your private daemon’s
logs for a scan limit. A small compressed upload can expand beyond MaxFileSize or
MaxScanSize. Raising the HTTP upload limit alone will not change either limit. If the daemon
stops responding, the application returns inconclusive after its scan deadline. Upload receipt
has a separate 15-second HTTP request timeout, which can close the connection before JSON is sent.
Keep the scanning boundary private
Before adapting this endpoint for a real upload pipeline, decide which formats you accept and
what to do with encrypted or otherwise uninspectable content. Keep pending files outside served
storage, bound concurrency, and monitor both signature freshness and incomplete scans. If you add
retention, move the same scanned bytes into their destination only after the result meets your
policy. Avoid exposing clamd’s unauthenticated protocol to untrusted clients.
