Vérifier par nombres magiques les téléversements d’API en Node.js
Un fichier téléversé nommé avatar.png n’est pas forcément un PNG. Ce tutoriel exécute un point de
terminaison Node.js local qui identifie les téléversements PNG et JPEG d’après leurs octets, rejette
les autres types détectés et applique des limites d’octets par fichier et par requête. Une réponse
réussie signifie que la signature correspondait à la liste d’autorisation, et non que l’image est
valide ou sûre.
Pourquoi la validation par extension de fichier ne suffit pas
Le nom de fichier et le Content-Type de la partie fichier sont fournis par le client. Renommer un
fichier ou déclarer image/png ne change pas son contenu. Le point de terminaison ci-dessous ignore
délibérément ces deux éléments pour décider si le type détecté est autorisé ; il n’utilise jamais un
nom de fichier client comme chemin de stockage.
Les recommandations de l’OWASP sur les téléversements
expliquent pourquoi les types MIME fournis par le client ne peuvent pas constituer une frontière de
sécurité.
Comprendre les nombres magiques et les signatures de fichiers
Les nombres magiques sont des séquences d’octets reconnaissables qui suggèrent le format d’un
fichier. Un PNG commence par 89 50 4E 47 0D 0A 1A 0A ; un JPEG commence par FF D8 FF. Une bibliothèque de
détection connaît davantage de détails de format qu’une courte vérification de préfixe écrite à la
main, mais elle ne décode toujours pas l’image complète.
La documentation de file-type
décrit la détection comme une indication fournie au mieux, et non comme une preuve de validité du
fichier. Certains fichiers endommagés ne produisent aucune correspondance ou lèvent une exception ;
d’autres conservent une signature reconnaissable. Ne nommez pas le résultat valid et ne
l’utilisez pas comme verdict antimalware.
Implémenter la validation par nombres magiques en Node.js
Utilisez Bash sous Linux, cURL, Node.js 24.15.0 ou une version plus récente de la branche 24 LTS, ainsi que Corepack avec Yarn 4.12.0 disponible. L’exemple a été testé avec Node.js 24.15.0 et 26.8.1. Pour le déploiement, utilisez une version LTS actuelle et à jour des correctifs, plutôt que de considérer le minimum testé comme une recommandation de mise à jour de sécurité.
Commencez dans un répertoire accessible en écriture. Cette procédure crée un nouveau projet magic-upload
sans modifier le répertoire de travail de votre shell. Elle refuse un répertoire de projet existant
et s’arrête avant l’installation si la création ou la navigation échoue. Le yarn.lock local
établit un projet Yarn distinct ; le linker node-modules permet à la simple commande node de
résoudre ses dépendances, y compris à l’intérieur d’un projet parent Yarn Plug’n’Play.
(
mkdir magic-upload &&
cd magic-upload &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
touch yarn.lock &&
YARN_NODE_LINKER=node-modules corepack yarn add --exact \
express@4.22.2 file-type@22.0.0 multer@2.4.0
)
Multer 2.4.0 fournit l’option streamHandler utilisée ci-dessous et inclut un
correctif de sécurité pour le nettoyage des téléversements.
Maintenez les dépendances de téléversement à jour des correctifs lorsque vous adaptez l’exemple.
Valider des téléversements limités en taille avec Express.js
Enregistrez le programme complet sous magic-upload/server.mts. La
suppression native des types de Node exécute ce fichier .mts
comme module ES sans compilateur ni chargeur TypeScript. Elle ne vérifie pas les types du programme
et ne lit pas de tsconfig.json englobant.
Le premier middleware utilise express.raw
pour lire le corps complet de la requête avec un plafond de 5 MiB + 16 KiB, avant l’analyse
multipart. Cela couvre les délimiteurs, les en-têtes de parties et les octets finaux du corps ainsi
que le contenu des fichiers, y compris pour les requêtes sans Content-Length. Multer applique ensuite une
limite distincte de 5 MiB par fichier et n’accepte qu’une seule partie fichier nommée file,
sans champ texte. Son streamHandler transmet le corps déjà lu
à l’analyseur multipart au lieu de relire le flux de requête épuisé.
import express, { type ErrorRequestHandler, type Request, type Response } from 'express'
import { fileTypeFromBuffer } from 'file-type'
import multer from 'multer'
const app = express()
const maxFileBytes = 5 * 1024 * 1024
const maxRequestBytes = maxFileBytes + 16 * 1024
const allowedTypes = new Set(['image/png', 'image/jpeg'])
async function identifyUpload(request: Request, response: Response): Promise<void> {
if (!request.file || request.file.size === 0) {
response.status(400).json({ error: 'Send one nonempty file in the file field' })
return
}
const type = await fileTypeFromBuffer(request.file.buffer)
if (type === undefined || !allowedTypes.has(type.mime)) {
response.status(415).json({ error: 'Only detected PNG or JPEG files are allowed' })
return
}
response.json({ detectedMime: type.mime, size: request.file.size })
}
app.post(
'/upload',
express.raw({ type: 'multipart/form-data', limit: maxRequestBytes, inflate: false }),
(request, response, next) => {
if (!Buffer.isBuffer(request.body)) {
response.status(415).json({ error: 'Send multipart/form-data' })
return
}
const body = request.body
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: maxFileBytes, files: 1, fields: 0, parts: 1 },
streamHandler: (_request, parser) => parser.end(body),
})
upload.single('file')(request, response, (error: unknown) => {
if (error) return next(error)
void identifyUpload(request, response).catch(() => {
response.status(400).json({ error: 'Unable to identify the file' })
})
})
},
)
const rejectUpload: ErrorRequestHandler = (error: unknown, _request, response, _next) => {
const tooLarge =
(error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') ||
(error instanceof Error && 'type' in error && error.type === 'entity.too.large')
response.status(tooLarge ? 413 : 400).json({
error: tooLarge ? 'Upload exceeds byte limit' : 'Malformed upload',
})
}
app.use(rejectUpload)
const port = Number(process.env.PORT ?? '3000')
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('PORT must be an integer between 0 and 65535')
}
const server = app.listen(port, '127.0.0.1')
server.on('listening', () => {
const address = server.address()
if (address !== null && typeof address !== 'string') {
console.log(`Listening on http://127.0.0.1:${address.port}`)
}
})
server.on('error', () => {
console.error('Could not start the upload server; check PORT and whether it is in use')
process.exitCode = 1
})
Depuis le répertoire où vous avez effectué la configuration, démarrez le serveur au premier plan :
node magic-upload/server.mts
Attendez l’URL d’écoute avant d’envoyer des requêtes. Si le port 3000 est occupé, définissez PORT
sur un autre port disponible en exécutant la même commande et adaptez l’URL de la requête en
conséquence. Arrêtez le serveur avec Ctrl+C ; vous revenez alors à votre shell. Le point de
terminaison écoute sur l’interface de bouclage, conserve les téléversements en mémoire et n’écrit
aucun fichier téléversé sur le disque.
Tester votre implémentation
Dans un second terminal, revenez au répertoire contenant magic-upload. Créez un PNG d’un pixel avec un
nom de fichier .bin trompeur. L’écriture en mode création seule refuse d’écraser un
sample.bin existant ; supprimez délibérément ce fichier de démonstration si vous souhaitez le recréer.
node --input-type=module -e '
import { writeFileSync } from "node:fs"
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQI12P4z8DwHwAFAAH/cpxSZwAAAABJRU5ErkJggg=="
writeFileSync("magic-upload/sample.bin", Buffer.from(png, "base64"), { flag: "wx" })
'
Téléversez-le avec un type MIME de partie fichier volontairement incorrect :
curl -fsS -F 'file=@magic-upload/sample.bin;type=text/plain' \
http://127.0.0.1:3000/upload
La réponse repose sur les octets du fichier reçu, et non sur .bin ou text/plain :
{"detectedMime":"image/png","size":70}
Prétendez maintenant qu’un texte ordinaire est un PNG. Cette commande affiche le statut HTTP sans traiter le rejet attendu comme un échec de cURL :
printf '%s' 'not an image' | curl -sS -o /dev/null -w '%{http_code}\n' \
-F 'file=@-;filename=avatar.png;type=image/png' http://127.0.0.1:3000/upload
Attendez-vous à 415. Testez également ces comportements lorsque vous adaptez le point de
terminaison :
- Un JPEG valide avec un nom de fichier sans rapport renvoie
image/jpeget le nombre réel d’octets de son fichier. - Un fichier manquant ou vide renvoie
400. Un corps multipart corrompu ou un champ inattendu renvoie également400. - Les signatures inconnues et les formats reconnus mais non autorisés, comme GIF ou PDF, renvoient
415. - Un contenu de fichier allant jusqu’à 5 MiB inclus passe la vérification de taille ; un octet de
plus renvoie
413. - Un corps de requête allant jusqu’à 5 MiB + 16 KiB inclus passe la vérification de taille de
requête ; un octet de plus renvoie
413, même si les octets supplémentaires suivent le dernier délimiteur multipart. Les vérifications de type et de formulaire s’appliquent toujours en dessous de l’une ou l’autre limite.
Gérer les cas limites et les fichiers polyglottes
Un préfixe FF D8 FF de trois octets peut être identifié comme JPEG sans contenir d’image complète.
Une image endommagée plus loin dans son contenu peut également passer. Le point de terminaison
signale délibérément detectedMime, et non un décodage réussi ni un verdict de sécurité. Un fichier
polyglotte peut être interprété comme plusieurs formats ; rechercher des séquences d’octets suspectes
à l’intérieur n’est pas une méthode de détection fiable.
Faites suivre l’identification de la signature d’un décodeur maintenu propre au format, de limites de dimensions et de pixels, ainsi que d’une analyse ou d’une reconstruction du contenu des fichiers, adaptées à votre modèle de menace. Exécutez les analyseurs traitant des données non fiables de manière isolée, avec des limites d’exécution imposées. L’exemple local ne fait rien de tout cela et ne rejette pas toutes les images malformées.
Garder explicites les limites de mémoire et de sécurité
Il s’agit d’une démonstration pour petits téléversements, et non d’un pipeline de streaming pour
gros fichiers. Le corps brut et le tampon de fichier de Multer coexistent, avec une surcharge
supplémentaire due à l’analyseur et aux allocations. Un plafond de taille de fichier ne borne ni la
mémoire totale du processus, ni les téléversements simultanés, ni le temps de détection. Les gros
fichiers nécessitent un chemin de stockage et une politique différents ; ni un préfixe de longueur
fixe ni une correspondance de signature réussie n’établissent que le reste d’un téléversement a été
vérifié. Ce tutoriel utilise Node.js de bout en bout ; il ne nécessite ni Python ni libmagic.
Combiner la validation par nombres magiques avec d’autres contrôles
Avant d’exposer un point de terminaison de téléversement, ajoutez l’authentification,
l’autorisation, des limites de débit et de concurrence, ainsi que des délais maximaux de requête à
la frontière appropriée de l’application ou du proxy. Maintenez les dépendances à jour des
correctifs. Si vous conservez les téléversements, générez les noms de stockage, placez les fichiers
en quarantaine avant traitement et évitez de servir du contenu actif depuis l’origine de votre
application. Il s’agit de contrôles distincts, et non de propriétés de file-type ; consultez
la liste de contrôle complète de l’OWASP pour les téléversements.
Résoudre les problèmes courants
- Aucune URL d’écoute : vérifiez le diagnostic de démarrage,
PORT, et si l’adresse est occupée. N’envoyez pas de téléversement de test à un service sans rapport utilisant déjà ce port. - Paquet introuvable : terminez l’installation dans
magic-uploadet utilisez le nom de fichier documenté.mts. Ne remplacez pas les imports ESM par desrequire()CommonJS. 400inattendu : envoyez exactement un fichier non vide nomméfileet aucun champ de formulaire supplémentaire. Une exception d’identification est également signalée sans exposer son erreur interne.413inattendu : vérifiez le corps multipart complet ainsi que la taille du fichier. Les en-têtes de parties et les délimiteurs consomment la marge supplémentaire de 16 KiB de la requête.
Conservez la distinction entre « identifié » et « validé » lorsque vous connectez ce point de terminaison au stockage ou au traitement. Pour un flux de travail de téléversement géré, découvrez notre service de gestion des téléversements de fichiers ; la politique définissant ce que votre application accepte vous appartient toujours.
