API d’envoi d’images sécurisée avec Node.js, Express et Multer
Un fichier envoyé ne devrait devenir téléchargeable qu’après que ses octets ont passé vos contrôles. Cet exemple reçoit un seul JPEG ou PNG, le garde en quarantaine pendant que ClamAV l’analyse, le décode avec Sharp, puis met le fichier original à disposition via une route de téléchargement Express. Les fichiers rejetés restent hors de cette route.
Le résultat est une API locale d’envoi d’images destinée aux développeurs qui testent un pipeline
d’envoi côté serveur. Elle écoute uniquement sur 127.0.0.1, accepte des fichiers jusqu’à 5 MiB et conserve
les octets acceptés, métadonnées d’image comprises. Elle ne gère ni comptes utilisateur ni
autorisation par fichier. Gardez-la en local tant que votre application ne fournit pas ces contrôles.
Configurer l’environnement Node.js
Utilisez Linux avec Node.js 26.8.1, Yarn 4.12.0 via Corepack, Docker et cURL. Ce sont la plateforme et les versions utilisées ici. Réservez 4 GiB de mémoire au conteneur ClamAV, en plus de la mémoire nécessaire à Node.js. Le démon Docker doit s’exécuter sur cette machine afin de pouvoir monter le répertoire de quarantaine.
Créez un nouveau projet. La chaîne && s’arrête si le répertoire existe déjà ou si une étape de
configuration échoue ; choisissez un autre nom de projet plutôt que de supprimer un répertoire
existant. Conservez le fichier de verrouillage généré.
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
Utilisez Multer 2.4.0 ou une version corrigée ultérieure. Les versions 2.2.0 à 2.3.0 peuvent laisser des fichiers orphelins lorsqu’un client se déconnecte avant qu’un callback de stockage asynchrone n’attribue le chemin. Le bulletin du mainteneur désigne la version 2.4.0 comme correctif. La gestion des erreurs dans l’application ne suffit pas, à elle seule, à corriger cette situation de concurrence dans la bibliothèque.
Analyse antivirus des fichiers envoyés
Depuis le nouveau répertoire du projet, démarrez un scanner privé. Cette image ClamAV 1.5.4 épinglée
inclut une base de signatures. Le conteneur n’a ni accès réseau ni ports publiés ; seul le dossier de
quarantaine est monté, en lecture seule. L’application invoque clamdscan à l’intérieur via Docker.
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
Attendez que le démon ait chargé sa base avant de démarrer l’API :
docker exec image-upload-clamav clamdscan --ping 120:1 &&
docker exec image-upload-clamav clamdscan --version
L’image épinglée indiquait la base 28129 datée du 20 septembre 2026, vieille de quatre jours au moment du test. Désactiver FreshClam fait de cet exemple une démonstration hors ligne, et non un service d’analyse mis à jour en continu. Un verdict sain signifie que ces signatures n’ont pas détecté de logiciel malveillant ; il ne certifie pas qu’un fichier est inoffensif. Pour un service déployé, maintenez les signatures à jour et surveillez l’état du scanner. Le guide Docker officiel explique la mise à jour de la base et les besoins en mémoire.
Créer la structure de base du point de terminaison de l’API
Enregistrez le programme complet suivant sous le nom app.ts dans le répertoire du projet. Node
exécute directement ce fichier TypeScript. Lancez-le depuis ce même répertoire afin que les chemins
de l’hôte correspondent au montage du scanner.
Le scanner n’accepte qu’un résultat sain, explicite et réussi pour le fichier vérifié. Un conteneur
absent, une erreur du démon, un délai dépassé ou une réponse inattendue entraîne le rejet de l’envoi.
--fdpass permet à clamdscan d’ouvrir le fichier privé et de transmettre son descripteur au démon
via son socket Unix ; consultez la documentation d’analyse de ClamAV.
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
})
Vérifier les octets avant de les publier
Multer gère l’enveloppe multipart et la limite de 5 MiB. Son filtre MIME est une vérification
précoce d’une étiquette fournie par le client. Sharp compare ensuite le format détecté à cette
étiquette et décode les pixels ; un fichier texte nommé photo.png ne passe pas. Le nom de fichier
original ne devient jamais un chemin de stockage. Les téléchargements reçoivent une extension
dérivée du format détecté.
metadata() de Sharp lit les en-têtes sans décoder les
données de pixels ; c’est pourquoi l’exemple appelle aussi raw().toBuffer(). Sharp décode l’image par défaut ;
cela ne valide pas chaque trame d’animation d’un fichier APNG. Le tampon décodé est abandonné, si
bien que le fichier stocké reste identique octet pour octet au fichier envoyé. La
limite de 12 millions de pixels et le décodage strict réduisent
l’exposition en matière de ressources. Ils ne constituent pas un bac à sable pour le décodeur natif.
Maintenez Sharp et ses bibliothèques natives à jour avec les correctifs. Les formats GIF, SVG et
autres ne font pas partie des entrées acceptées par cet exemple.
Deux envois peuvent être en cours de réception, d’analyse ou de décodage simultanément. Les envois
supplémentaires reçoivent 503 ; le délai de 60 secondes ferme la connexion du client ; un
traitement natif déjà en cours se termine avant que son emplacement ne soit libéré. Le limiteur par
IP autorise dix tentatives par tranche de 15 minutes, tentatives rejetées comprises. Ses compteurs
en mémoire sont réinitialisés avec le processus et ne sont pas partagés entre serveurs. Aucune de
ces limites ne fournit de quota par compte ni ne borne le nombre de fichiers acceptés qui
s’accumulent au fil du temps.
Démarrer l’API et interpréter les échecs
Démarrez le serveur au premier plan une fois le scanner prêt :
node app.ts
Attendez Ready at http://127.0.0.1:3000. Au démarrage, le programme effectue une véritable analyse via le répertoire de
quarantaine monté avant d’ouvrir le port. Si le port 3000 est occupé, choisissez-en un autre avec
PORT=3007 node app.ts et utilisez ce port dans les commandes cURL. Express 5 transmet les erreurs de liaison au
callback app.listen ; dans ce cas, ce programme se
termine en échec au lieu d’afficher une URL prête.
| Statut | Signification |
|---|---|
201 | Analysé et décodé ; les octets originaux sont disponibles à l’URL renvoyée. |
400 | Fichier manquant, champ inattendu ou limite multipart de Multer autre que la taille du fichier. |
413 | Le fichier dépasse 5 MiB. |
415 | Type MIME non pris en charge, octets non concordants, image invalide ou dépassement de la limite de pixels. |
422 | ClamAV a détecté un logiciel malveillant. |
429 | Trop de tentatives d’envoi depuis cette IP. |
503 | Défaillance du scanner ou deux envois déjà en cours de traitement. |
500 | Défaillance inattendue d’analyse syntaxique, du système de fichiers ou du serveur. |
Pour les requêtes rejetées ordinaires, le fichier est supprimé de la quarantaine. La version corrigée de Multer gère aussi les écritures interrompues, y compris le callback asynchrone de nom de fichier. Un plantage du processus ou un arrêt forcé peut tout de même laisser des fichiers en quarantaine ; inspectez-les et supprimez-les uniquement lorsque l’API est arrêtée. Une fois que le serveur a enregistré définitivement un fichier accepté, celui-ci reste stocké même si le client perd la réponse.
Tester l’API avec cURL
Ouvrez un autre terminal dans le répertoire du projet. Créez un minuscule PNG valide sans avoir à
télécharger d’image d’exemple. Cette commande refuse d’écraser un fichier sample.png existant :
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" });
'
Envoyez-le, puis téléchargez l’URL indiquée dans la réponse JSON. Grâce à l’option noclobber du
shell, ce bloc refuse d’écraser les fichiers de résultat existants. Utilisez de nouveaux noms de
fichiers pour une autre exécution. cmp n’affiche rien et se termine avec succès lorsque les
octets téléchargés correspondent à l’original.
(
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
)
Pour vérifier simplement un rejet, envoyez le manifeste du projet en le déclarant comme PNG :
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
Attendez-vous à 415, à aucun nouveau fichier accepté et à un répertoire de quarantaine vide après
la requête. Si vous arrêtez le scanner avec docker stop image-upload-clamav pendant que l’API tourne, une image valide
reçoit à la place 503. Redémarrez-le avec la commande docker run précédente, en laissant en place les
répertoires existants du projet, et attendez qu’il soit prêt avant de réessayer.
Décider ce qu’il faut conserver et qui peut le télécharger
Les deux répertoires se trouvent hors de toute racine web statique, sur le même système de fichiers, afin que la publication puisse utiliser un lien physique. Les nouveaux fichiers ne sont accessibles qu’au compte du système d’exploitation qui exécute Node. Les fichiers acceptés reçoivent des noms aléatoires et ne sont jamais écrasés ; envoyer à nouveau la même image crée un autre fichier. Une fois terminé, arrêtez l’API avec Ctrl+C, puis arrêtez son scanner. Les deux répertoires restent sur le disque pour que vous puissiez les inspecter ou les supprimer délibérément.
Les journaux d’erreurs contiennent les codes de statut, sans noms de fichiers du client ni sortie du
scanner. La route de téléchargement sert des pièces jointes avec nosniff et no-store. Elle
ne supprime pas les données EXIF, les données de localisation, les octets de fin ni tout autre
contenu intégré. La réussite de l’analyse et du décodage garantit moins qu’un assainissement. Si
vous avez besoin d’une image publique normalisée, ajoutez une étape de réencodage distincte et
vérifiez sa politique de métadonnées et de format avant d’exposer sa sortie.
Avant de connecter ce pipeline à une application publique ou à un stockage d’objets privé, ajoutez l’authentification, l’autorisation par fichier, des quotas de stockage et une politique de rétention. Les URL aléatoires et CORS ne fournissent pas de vérification de propriété. L’adaptateur de commande Docker est pratique pour une démonstration locale, mais il donne au processus Node l’accès au démon Docker ; dans un service déployé, utilisez une intégration de scanner dédiée aux privilèges plus restreints. Ce tutoriel ne configure ni ne vérifie de chemin de stockage dans le cloud.
