Sichere Bild-Upload-API mit Node.js, Express und Multer
Ein Upload sollte erst zum Download bereitstehen, nachdem seine Bytes Ihre Prüfungen bestanden haben. Dieses Beispiel empfängt eine JPEG- oder PNG-Datei, hält sie während des ClamAV-Scans in Quarantäne, decodiert sie mit Sharp und stellt dann die Originaldatei über eine Download-Route in Express bereit. Abgelehnte Dateien bleiben von dieser Route ausgeschlossen.
Das Ergebnis ist eine lokale Bild-Upload-API für Entwickler, die eine serverseitige Upload-Pipeline
testen. Sie lauscht nur auf 127.0.0.1, akzeptiert Dateien bis 5 MiB und erhält
akzeptierte Bytes einschließlich der Bildmetadaten unverändert. Sie hat weder Benutzerkonten noch
eine Autorisierung pro Datei. Betreiben Sie sie nur lokal, bis Ihre Anwendung diese Kontrollen
bereitstellt.
Die Node.js-Umgebung einrichten
Verwenden Sie Linux mit Node.js 26.8.1, Yarn 4.12.0 über Corepack, Docker und cURL. Diese Versionen und diese Plattform werden hier verwendet. Reservieren Sie 4 GiB Arbeitsspeicher für den ClamAV-Container zusätzlich zum Speicherbedarf von Node.js. Der Docker-Daemon muss auf diesem Rechner laufen, damit er das Quarantäneverzeichnis einbinden kann.
Erstellen Sie ein neues Projekt. Die Befehlskette mit && stoppt, wenn das
Verzeichnis bereits existiert oder ein Einrichtungsschritt fehlschlägt. Wählen Sie einen anderen
Projektnamen, statt ein vorhandenes Verzeichnis zu entfernen. Behalten Sie die erzeugte Lockdatei.
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
Verwenden Sie Multer 2.4.0 oder eine neuere gepatchte Version. Die Versionen 2.2.0 bis 2.3.0 können verwaiste Dateien hinterlassen, wenn ein Client die Verbindung trennt, bevor ein asynchroner Speicher-Callback den Pfad zuweist. Der Sicherheitshinweis der Maintainer nennt 2.4.0 als korrigierte Version. Die Fehlerbehandlung der Anwendung allein behebt diese Race Condition in der Bibliothek nicht.
Hochgeladene Dateien auf Viren scannen
Starten Sie im neuen Projektverzeichnis einen privaten Scanner. Dieses auf ClamAV 1.5.4 fixierte
Image enthält eine Signaturdatenbank. Der Container hat weder Netzwerkzugriff noch veröffentlichte
Ports. Nur der Quarantäneordner wird eingebunden, und zwar schreibgeschützt. Die Anwendung ruft
clamdscan darin über Docker auf.
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
Warten Sie, bis der Daemon seine Datenbank geladen hat, bevor Sie die API starten:
docker exec image-upload-clamav clamdscan --ping 120:1 &&
docker exec image-upload-clamav clamdscan --version
Das fixierte Image meldete die Datenbank 28129 vom 20. September 2026, die beim Test vier Tage alt war. Durch das Deaktivieren von FreshClam bleibt dies eine Offline-Demonstration und kein laufend aktualisierter Scan-Dienst. Ein unauffälliges Scan-Ergebnis bedeutet, dass diese Signaturen keine Malware erkannt haben. Es bescheinigt nicht, dass eine Datei harmlos ist. Halten Sie bei einem bereitgestellten Dienst die Signaturen aktuell und überwachen Sie den Zustand des Scanners. Die offizielle Docker-Anleitung erläutert Datenbankaktualisierungen und Speicheranforderungen.
Die grundlegende API-Endpunktstruktur erstellen
Speichern Sie das folgende vollständige Programm als app.ts im
Projektverzeichnis. Node führt diese TypeScript-Datei direkt aus. Starten Sie sie aus demselben
Verzeichnis, damit die Host-Pfade mit der Einbindung des Scanners übereinstimmen.
Der Scanner akzeptiert für die gescannte Datei nur ein erfolgreiches, explizit unauffälliges
Ergebnis. Ein fehlender Container, ein Daemon-Fehler, eine Zeitüberschreitung oder eine unerwartete
Antwort führt zur Ablehnung des Uploads. --fdpass ermöglicht es
clamdscan, die private Datei zu öffnen und ihren Deskriptor über den
Unix-Socket des Daemons an diesen zu übergeben. Siehe die
ClamAV-Dokumentation zum Scannen.
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
})
Bytes vor der Veröffentlichung prüfen
Multer verarbeitet die Multipart-Hülle und setzt das Limit von 5 MiB durch. Sein MIME-Filter prüft
frühzeitig eine vom Client gelieferte Kennzeichnung. Sharp gleicht anschließend das erkannte Format
mit dieser Kennzeichnung ab und decodiert die Pixel. Eine Textdatei namens
photo.png besteht diese Prüfung nicht. Der ursprüngliche Dateiname wird nie
als Speicherpfad verwendet. Downloads erhalten eine Dateiendung, die vom erkannten Format
abgeleitet wird.
Die Funktion metadata() von Sharp liest Header,
ohne Pixeldaten zu decodieren. Deshalb ruft das Beispiel auch raw().toBuffer() auf.
Sharp decodiert das Standardbild. Damit wird nicht jeder Animationsframe einer APNG-Datei
validiert. Der decodierte Puffer wird verworfen, sodass die gespeicherte Datei Byte für Byte mit
dem Upload identisch bleibt. Das
Limit von 12 Millionen Pixeln und die strikte Decodierung
begrenzen die potenzielle Ressourcenbelastung. Sie bieten keine Sandbox für den nativen Decoder.
Halten Sie Sharp und seine nativen Bibliotheken durch Patches aktuell. GIF, SVG und andere Formate
gehören nicht zu den akzeptierten Eingaben dieses Beispiels.
Zwei Uploads können gleichzeitig empfangen, gescannt oder decodiert werden. Weitere Uploads erhalten
503. Nach Ablauf der Frist von 60 Sekunden wird die Clientverbindung
geschlossen. Eine bereits laufende native Verarbeitung wird beendet, bevor ihr Slot freigegeben
wird. Der IP-Limiter erlaubt zehn Versuche pro 15 Minuten, einschließlich abgelehnter Versuche.
Seine Zähler im Arbeitsspeicher werden mit dem Prozess zurückgesetzt und nicht zwischen Servern
geteilt. Keines der beiden Limits stellt ein Kontokontingent bereit oder begrenzt, wie viele
akzeptierte Dateien sich im Laufe der Zeit ansammeln.
Die API starten und Fehler einordnen
Starten Sie den Server im Vordergrund, sobald der Scanner bereit ist:
node app.ts
Warten Sie auf Ready at http://127.0.0.1:3000. Beim Start wird ein tatsächlicher Scan über das
eingebundene Quarantäneverzeichnis durchgeführt, bevor der Port geöffnet wird. Wenn Port 3000
belegt ist, wählen Sie mit PORT=3007 node app.ts einen anderen und verwenden Sie diesen
Port in den cURL-Befehlen. Express 5 übergibt Bindefehler an den
Callback von app.listen. Dieses Programm wird
in diesem Fall mit einem Fehlerstatus beendet, statt eine URL als einsatzbereit auszugeben.
| Status | Bedeutung |
|---|---|
201 | Gescannt und decodiert; die ursprünglichen Bytes sind unter der zurückgegebenen URL verfügbar. |
400 | Fehlende Datei, unerwartetes Feld oder ein anderes Multipart-Limit von Multer als die Dateigröße. |
413 | Die Datei überschreitet 5 MiB. |
415 | Nicht unterstützter MIME-Typ, nicht übereinstimmende Bytes, ungültiges Bild oder überschrittenes Pixellimit. |
422 | ClamAV hat Malware erkannt. |
429 | Zu viele Upload-Versuche von dieser IP. |
503 | Scannerfehler oder bereits zwei Uploads in Verarbeitung. |
500 | Unerwarteter Parsing-, Dateisystem- oder Serverfehler. |
Bei regulär abgelehnten Anfragen wird die Datei aus der Quarantäne entfernt. Gepatchtes Multer behandelt auch abgebrochene Schreibvorgänge, einschließlich des asynchronen Dateinamen-Callbacks. Ein Prozessabsturz oder erzwungenes Herunterfahren kann dennoch Quarantänedateien hinterlassen. Prüfen und entfernen Sie diese nur, während die API gestoppt ist. Sobald der Server eine akzeptierte Datei endgültig gespeichert hat, bleibt sie gespeichert, selbst wenn die Antwort beim Client nicht ankommt.
Die API mit cURL testen
Öffnen Sie ein weiteres Terminal im Projektverzeichnis. Erstellen Sie eine winzige, gültige
PNG-Datei, ohne ein Beispielbild herunterladen zu müssen. Eine vorhandene Datei
sample.png wird dabei nicht überschrieben:
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" });
'
Laden Sie die Datei hoch und anschließend über die URL aus der JSON-Antwort herunter. Durch die
noclobber-Einstellung der Shell verweigert dieser Block das Überschreiben vorhandener
Ergebnisdateien. Verwenden Sie für einen weiteren Durchlauf neue Dateinamen.
cmp gibt nichts aus und wird erfolgreich beendet, wenn die
heruntergeladenen Bytes mit dem Original übereinstimmen.
(
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
)
Um eine einfache Ablehnung zu testen, senden Sie das Projektmanifest mit der Angabe, es sei eine PNG-Datei:
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
Erwarten Sie 415, keine neue akzeptierte Datei und ein leeres
Quarantäneverzeichnis nach der Anfrage. Wenn Sie den Scanner bei laufender API mit
docker stop image-upload-clamav stoppen, erhält ein gültiges Bild stattdessen
503. Starten Sie ihn mit dem vorherigen Befehl
docker run neu. Lassen Sie dabei die vorhandenen Projektverzeichnisse
unverändert und warten Sie, bis der Scanner bereit ist, bevor Sie es erneut versuchen.
Festlegen, was aufbewahrt wird und wer es herunterladen darf
Die beiden Verzeichnisse liegen außerhalb aller statischen Web-Stammverzeichnisse auf demselben Dateisystem, sodass zur Veröffentlichung ein Hardlink verwendet werden kann. Neue Dateien sind nur für das Betriebssystemkonto zugänglich, unter dem Node läuft. Akzeptierte Dateien erhalten zufällige Namen und werden nie überschrieben. Ein erneuter Upload desselben Bildes erzeugt eine weitere Datei. Stoppen Sie die API mit Ctrl+C und ihren Scanner, wenn Sie fertig sind. Beide Verzeichnisse bleiben auf dem Datenträger, damit Sie sie prüfen oder gezielt entfernen können.
Fehlerprotokolle enthalten Statuscodes ohne Client-Dateinamen oder Scanner-Ausgaben. Die
Download-Route liefert Anhänge mit nosniff und
no-store aus. Sie entfernt keine EXIF-Daten, Standortdaten, nachgestellten
Bytes oder anderen eingebetteten Inhalte. Ein erfolgreicher Scan und eine erfolgreiche
Decodierung sagen weniger aus als eine Bereinigung. Wenn Sie ein normalisiertes öffentliches Bild
benötigen, ergänzen Sie einen separaten Schritt zum erneuten Encoding und überprüfen Sie dessen
Metadaten- und Formatrichtlinie, bevor Sie die Ausgabe zugänglich machen.
Bevor Sie diese Pipeline mit einer öffentlichen Anwendung oder einem privaten Objektspeicher verbinden, ergänzen Sie Authentifizierung, Autorisierung pro Datei, Speicherkontingente und eine Aufbewahrungsrichtlinie. Zufällige URLs und CORS prüfen keine Eigentumsrechte. Der Docker-Befehlsadapter ist für eine lokale Demonstration praktisch, gewährt dem Node-Prozess aber Zugriff auf den Docker-Daemon. Verwenden Sie in einem bereitgestellten Dienst eine dedizierte Scanner-Integration mit enger begrenzten Berechtigungen. Dieses Tutorial konfiguriert und überprüft keinen Cloud-Speicherpfad.
