Serverseitige Malware-Scans mit ClamAV in Node.js umsetzen
Halten Sie einen Upload privat, bis sein Malware-Scan abgeschlossen ist. Diese Anleitung erstellt einen lokalen Node.js-Endpunkt, der zwischen keinem Fund, einem Fund und einem unvollständigen Scan unterscheidet und anschließend die hochgeladene Kopie löscht. Sie testen ihn mit gewöhnlichem Text und der harmlosen EICAR-Antivirus-Testzeichenfolge.
Festlegen, was ein Scanergebnis bedeutet
Der Daemon clamd von ClamAV hält seine Antivirus-Engine geladen und nimmt
Scananfragen über einen Socket an. Das Node.js-Paket clamscan ist ein Client
für diesen Daemon. Seine Methode scanStream sendet Bytes, sodass der Daemon keinen
Zugriff auf das Uploadverzeichnis der Anwendung benötigt.
Ein abgeschlossener Scan ohne Fund beweist nicht, dass eine Datei sicher ist. Nicht unterstützte Formate, verschlüsselte Inhalte, neue Bedrohungen und formatspezifische Scangrenzen bleiben relevant. Dieses Beispiel weist Uploads bei gemeldeten Fehlern und Grenzwertwarnungen zurück; es beansprucht nicht, jeden Grund zu erkennen, aus dem eine Datei dem Scan entgehen könnte. Behalten Sie die Dateitypvalidierung und eine sichere nachgelagerte Verarbeitung als separate Schutzmaßnahmen bei.
ClamAV auf Ihrem Server einrichten
Verwenden Sie Linux, Node.js 24.15.0, Corepack mit Yarn 4.12.0 und cURL. Der folgende Scanablauf wurde
mit ClamAV 1.5.4 und clamscan@2.4.0 getestet. Bevor Sie die Node-Anwendung starten,
benötigen Sie einen laufenden privaten clamd mit einer offiziellen
Signaturdatenbank. Folgen Sie der
ClamAV-Installationsanleitung und der
Anleitung zur Daemon-Konfiguration, falls Sie noch keinen haben.
Die Installation der Datenbank und die Dienstverwaltung hängen von Ihrer Linux-Distribution ab.
Konfigurieren Sie Ihren dedizierten Test-Daemon mit diesen Grenzwerten und starten Sie ihn neu.
Behalten Sie seine vorhandenen Pfade für DatabaseDirectory und
LocalSocket bei. Gewähren Sie nur dem Anwendungsbenutzer Zugriff auf den Socket
und dessen übergeordnetes Verzeichnis; aktivieren Sie keinen öffentlichen TCP-Listener.
StreamMaxLength 26M
MaxFileSize 25M
MaxScanSize 100M
MaxRecursion 16
MaxFiles 1000
AlertExceedsMax yes
Diese Grenzwerte erfüllen unterschiedliche Aufgaben. StreamMaxLength begrenzt die
über den Socket gesendeten Bytes. MaxFileSize gilt für einzelne Dateien,
einschließlich entpackter Archivdateien. MaxScanSize begrenzt den gesamten
Scanaufwand pro Eingabe, einschließlich entpackter Inhalte. Mit AlertExceedsMax
erzeugen unterstützte Grenzwertüberschreitungen Warnungen vom Typ
Heuristics.Limits.Exceeded. Der folgende Wrapper behandelt diese als unvollständige Scans,
nicht als Malware-Funde. Den genauen Geltungsbereich jedes Grenzwerts finden Sie in der
versionierten Konfigurationsreferenz.
Aktualisieren Sie offizielle Signaturen mit FreshClam und überwachen Sie ihr Alter. Führen Sie keinen zweiten Updater für eine Datenbank aus, die bereits von einem FreshClam-Dienst verwaltet wird. Dieses Tutorial verwendet die geladene Datenbank Ihres Daemons; das Node-Paket lädt weder Signaturen herunter noch ändert es die Grenzwerte des Daemons.
ClamAV in Node.js integrieren
Fügen Sie diesen Einrichtungsblock in einem Verzeichnis mit Schreibzugriff ein. Er erstellt ein
neues Projekt namens node-clamav und überschreibt kein vorhandenes Verzeichnis.
Die explizite Yarn-Konfiguration hält es von einem übergeordneten Projekt getrennt. Beheben Sie
eine fehlgeschlagene Installation, bevor Sie fortfahren.
(
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
)
Speichern Sie die folgenden drei Dateien in node-clamav. Sie verwenden explizite
CommonJS-Erweiterungen vom Typ .cjs und laufen direkt mit Node, ohne
Build-Schritt.
Speichern Sie zuerst ClamAVScanner.cjs. Der Wrapper verwendet die
Streaming-API des Pakets sowohl für Dateien als auch für den Starttest.
Er richtet vor dem Verbindungsaufbau einen Fehler-Listener ein, da die Eingabedatei verschwinden
kann, während der Client seinen Socket öffnet.
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
Speichern Sie als Nächstes scan-worker.cjs. Jeder Worker besitzt eine
Clientverbindung. Ein fehlender Dateipfad fordert einen kleinen Startscan an; ein tatsächlicher
Upload verwendet seinen privaten temporären Pfad.
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' })
})
Hochgeladene Dateien mit ClamAV scannen
Speichern Sie server.cjs. Der Server lauscht nur auf Loopback und nimmt jeweils
nur einen Upload an. Multer schreibt das einzelne Feld
file in ein neues privates Verzeichnis. Uploads dürfen bis zu 25 MiB groß
sein. Kein hochgeladener Dateiname wird zu einem Dateisystempfad, und die Anwendung liefert diese
Verzeichnisse niemals aus.
Die Scanfrist von 10 Sekunden umfasst die Clientinitialisierung. Ein abgelehntes Promise allein
bricht keine Socketoperation ab. Deshalb wartet die Anwendung auf
worker.terminate(),
bevor sie die temporäre Eingabe löscht. Das stoppt den Node-Client; es garantiert nicht, dass
bereits von clamd angenommene Arbeit abgebrochen wird. Die eigenen
Grenzwerte des Daemons gelten weiterhin.
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
})
Starten Sie den Server aus dem übergeordneten Verzeichnis in einem Terminal. Ersetzen Sie dabei
den Socketpfad durch den Wert von LocalSocket Ihres Daemons. Einige Ubuntu-Pakete
verwenden beispielsweise /var/run/clamav/clamd.ctl. Der Starttest muss erfolgreich sein,
bevor die URL des betriebsbereiten Servers erscheint. Setzen Sie PORT
auf einen anderen Port, falls 3000 belegt ist.
(cd node-clamav && CLAMD_SOCKET=/absolute/path/to/clamd.sock node server.cjs)
Eine harmlose Datei und die EICAR-Testzeichenfolge senden
Fügen Sie diesen Block in einem zweiten Terminal im selben übergeordneten Verzeichnis ein. Er erstellt neue Testdateien, ohne vorhandene Dateien zu ersetzen. EICAR ist ein harmloses Antivirus-Testmuster, keine echte Malware; Ihr Antivirusprogramm kann es in Quarantäne verschieben. Deaktivieren Sie den Schutz nicht, um es zu behalten.
(
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
)
Die erste Anfrage sollte HTTP 200 und {"result":"no-detection"} zurückgeben. Die EICAR-Anfrage
sollte HTTP 403 und {"result":"detected"} zurückgeben. Diese Befehle lassen die cURL-Option
-f bewusst weg, damit Sie die Antworten bei einer Ablehnung untersuchen
können. Falls Sie PORT geändert haben, aktualisieren Sie beide URLs.
Die von Ihnen erstellten Dateien bleiben in Ihrem Projekt; nur die auf den Server hochgeladenen
Kopien werden gelöscht.
Fehler interpretieren und Bereinigung prüfen
| HTTP-Status | Ergebnis | Bedeutung |
|---|---|---|
| 200 | no-detection | Der konfigurierte Scan meldete keinen bekannten Fund. |
| 403 | detected | Der Scanner meldete einen Fund. |
| 400 | invalid-upload | Fehlende, leere oder nicht unterstützte Multipart-Felder. |
| 413 | invalid-upload | Der Upload überschritt 25 MiB. |
| 503 | inconclusive | Ein Scanfehler, ein Upload-Parserfehler oder eine Grenzwertwarnung verhinderte ein Ergebnis. |
| 503 | busy | Eine andere Anfrage ist aktiv oder der Server wird beendet. |
| 500 | cleanup-failed | Das Löschen ist fehlgeschlagen; der Server lehnt weitere Uploads ab. |
Die Bereinigung erfolgt vor der JSON-Antwort, auch bei abgelehnten Uploads und fehlgeschlagenen Scans. Ein Dateisystemfehler kann das Löschen dennoch verhindern; der Server meldet ihn und nimmt keine weitere Arbeit an. Strg+C stoppt neue Verbindungen, lässt aktive Anfragen abschließen und entfernt das temporäre Stammverzeichnis des Prozesses. Ein Absturz oder erzwungenes Beenden kann Dateien zurücklassen. Dies ist eine lokale Scan-Demo ohne Aufbewahrung von Uploads, Authentifizierung oder Verfügbarkeitsgarantie für den Produktivbetrieb.
Häufige Probleme beheben
Falls der Start fehlschlägt, prüfen Sie, ob CLAMD_SOCKET einen lauschenden Socket
angibt und Ihr Benutzer dessen übergeordnete Verzeichnisse durchlaufen kann.
ENOENT bedeutet einen fehlenden Pfad, EACCES ein
Berechtigungsproblem und EADDRINUSE einen belegten HTTP-Port. Beheben Sie die
Ursache und starten Sie neu; die Anwendung wechselt nicht stillschweigend zu einem anderen Scanner.
Falls eine kleine Textdatei funktioniert, ein Archiv aber inconclusive zurückgibt,
prüfen Sie die Logs Ihres privaten Daemons auf einen Scangrenzwert. Ein kleiner komprimierter
Upload kann nach dem Entpacken MaxFileSize oder MaxScanSize
überschreiten. Eine alleinige Erhöhung des HTTP-Uploadlimits ändert keinen dieser beiden
Grenzwerte. Falls der Daemon nicht mehr antwortet, gibt die Anwendung nach Ablauf ihrer Scanfrist
inconclusive zurück. Für den Uploadempfang gilt ein separates Zeitlimit von
15 Sekunden für HTTP-Anfragen, das die Verbindung schließen kann, bevor JSON gesendet wird.
Die Scanumgebung privat halten
Bevor Sie diesen Endpunkt für eine echte Uploadpipeline anpassen, entscheiden Sie, welche Formate
Sie akzeptieren und wie Sie mit verschlüsselten oder anderweitig nicht scanbaren Inhalten umgehen.
Halten Sie ausstehende Dateien außerhalb des Speichers, aus dem Dateien ausgeliefert werden,
begrenzen Sie die Parallelität und überwachen Sie sowohl die Aktualität der Signaturen als auch
unvollständige Scans. Wenn Sie eine Aufbewahrung hinzufügen, verschieben Sie dieselben gescannten
Bytes erst dann an ihren Zielort, wenn das Ergebnis Ihre Richtlinie erfüllt. Machen Sie das
Protokoll von clamd, das keine Authentifizierung vorsieht, nicht für
nicht vertrauenswürdige Clients zugänglich.
