12k
All articles

Gestion des téléversements d'images côté serveur

Sécurité des uploads d’images Node : validez la signature des fichiers en mémoire, limitez la taille, réencodez avec sharp et servez-les en sécurité.

OpenReplay Team
OpenReplay Team
Gestion des téléversements d'images côté serveur

La validation des fichiers côté serveur commence à l’arrivée de la requête multipart, et à ce stade, la seule chose digne de confiance, ce sont les octets. Le Content-Type et le nom de fichier proviennent tous deux du navigateur : traitez-les comme des affirmations, non comme des faits. Si vous arrivez ici après avoir lu les volets côté navigateur sur la création de vignettes d’images avant le téléversement et la conversion d’images en Base64 avec Canvas, voici l’extrémité réceptrice de ce pipeline.

Les deux moitiés du pipeline remplissent des rôles distincts. Le redimensionnement et la compression côté navigateur sont un service rendu à l’utilisateur ; la validation côté serveur, elle, est ce qui bloque un corps de requête de 2 Go ou un script déguisé sous une extension .jpg. Cet article passe en revue cinq contrôles indispensables à un endpoint d’images Node : la vérification de signature, des limites de taille en couches, des noms de fichiers générés, le ré-encodage et une diffusion sécurisée.

Points clés

  • L’en-tête Content-Type et le nom de fichier d’un téléversement multipart sont tous deux définis par le client : un serveur qui vérifie l’un ou l’autre valide donc l’affirmation de l’attaquant à propos du fichier, et non le fichier lui-même.
  • Une vérification de signature doit lire les premiers octets du fichier en mémoire, avant que quoi que ce soit ne touche le disque, et les comparer aux formats que cet endpoint précis accepte.
  • Les limites de taille se placent d’abord au niveau du proxy, ensuite dans l’option limits de Multer, et en dernier lieu dans le code applicatif ; le fileSize de Multer vaut Infinity par défaut, donc une limite non définie équivaut à une absence de limite.
  • Le ré-encodage avec sharp est ce qui rend l’usurpation de signature sans effet : la sortie est un nouveau fichier, et les métadonnées EXIF, y compris les coordonnées GPS des photos de téléphone, n’y survivent pas.
  • Diffusez les images stockées avec un Content-Type issu de votre propre enregistrement de validation, l’en-tête X-Content-Type-Options: nosniff, et un chemin de stockage situé en dehors de la racine web.

Pourquoi les vérifications de type MIME et d’extension échouent-elles ?

Vérifier file.mimetype ou l’extension du nom de fichier ne valide pas le fichier, car le client fournit ces deux valeurs. Deux articles antérieurs de ce blog donnaient ce conseil : Multer NPM: File Upload in Node.js présente un fileFilter qui accepte tout fichier dont le mimetype figure dans une liste autorisée, et Safe User Input Handling in Node.js invite les lecteurs à valider les types MIME au niveau du parseur. Ces deux vérifications valent la peine d’être conservées comme rejets précoces peu coûteux, mais aucune des deux ne constitue un contrôle de sécurité.

La falsification tient en une ligne :

curl -F "file=@payload.sh;type=image/jpeg" https://example.com/upload

Multer recopie cette valeur type directement dans req.file.mimetype. Votre filtre voit image/jpeg ; le corps est un script shell. La OWASP File Upload Cheat Sheet est explicite : la validation doit se fonder sur le contenu du fichier, et non sur des métadonnées fournies par le client.

La validation des téléversements commence par la signature du fichier

Lisez les premiers octets du buffer, en mémoire, avant toute écriture sur disque, et comparez-les aux formats que cet endpoint précis accepte. Les deux moitiés de cette phrase importent. Les tutoriels valident couramment après que le fichier a déjà atterri dans un répertoire d’uploads, ce qui signifie qu’une charge utile surdimensionnée ou hostile a déjà fait son œuvre avant l’exécution de la vérification. Et ils laissent couramment passer un fichier dès lors qu’il correspond à une signature connue, si bien qu’un PDF traverse sans encombre un endpoint d’avatar. Un endpoint d’avatar qui reconnaît les PDF a un bug de validation, pas une fonctionnalité.

Les signatures elles-mêmes : un JPEG commence par FF D8 FF, un PNG commence par la séquence complète de 8 octets définie dans la spécification PNG, et un WebP exige RIFF à l’offset 0 plus WEBP aux octets 8 à 11, conformément à la spécification du conteneur WebP. Les octets 4 à 7 correspondent à la taille du chunk RIFF, ce qui explique pourquoi une vérification WebP doit les ignorer, et pourquoi une lecture naïve de 4 octets ne traite correctement ni PNG ni WebP.

const SIGNATURES = {
  "image/jpeg": (b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
  "image/png": (b) =>
    b.length >= 8 &&
    b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
  "image/webp": (b) =>
    b.length >= 12 &&
    b.subarray(0, 4).toString("ascii") === "RIFF" &&
    b.subarray(8, 12).toString("ascii") === "WEBP",
};

// Allowlist is per endpoint: this one accepts photos, nothing else
function detectImageType(buffer, allowed = ["image/jpeg", "image/png", "image/webp"]) {
  return allowed.find((type) => SIGNATURES[type](buffer)) ?? null;
}

Une réserve : les signatures peuvent être falsifiées en préfixant les bons octets à n’importe quelle charge utile, en quelques secondes. Le package file-type le dit lui-même dans son README, où la correspondance des nombres magiques est décrite comme un indice sur le format plutôt qu’une preuve. Considérez la vérification de signature comme un filtre rapide et peu coûteux. La frontière de sécurité arrive deux sections plus loin.

Où placer les limites de taille de téléversement ?

Les limites de taille se placent d’abord au niveau du proxy, ensuite au niveau du framework, et en dernier lieu dans le code applicatif, car une vérification qui s’exécute après la mise en tampon du corps de requête ne rejette le téléversement qu’après que la charge utile complète vous a déjà coûté de la mémoire et de la bande passante. Sous nginx, client_max_body_size vaut 1 Mo par défaut et répond aux requêtes surdimensionnées par un 413 avant que votre processus ne les voie :

client_max_body_size 5m;

La couche framework, c’est Multer (2.x), où limits.fileSize vaut Infinity par défaut : une limite non définie équivaut donc à une absence de limite.

const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024, files: 1 },
});

Lorsque le plafond est atteint, Multer lève une erreur LIMIT_FILE_SIZE. Sur Fastify, @fastify/multipart adopte la position inverse et fixe fileSize à 1 Mio par défaut : le comportement sûr est donc celui par défaut, et non une option à activer. Les session replays des parcours de téléversement rendent le coût de l’omission de la couche proxy visible sous forme de défaut d’UX : la barre de progression atteint 100 %, puis le rejet apparaît, parce que l’intégralité de la charge utile devait arriver avant que la vérification applicative puisse s’exécuter.

Écartez le nom de fichier du client

Ne laissez jamais le nom de fichier du client toucher votre système de fichiers. Générez le vôtre à partir de crypto.randomUUID() et de l’extension déterminée par votre propre validation :

const storedName = `${crypto.randomUUID()}.jpg`;

Le raisonnement, des séquences de traversée jusqu’aux raisons pour lesquelles path.normalize n’est pas une défense, est traité dans Preventing Path Traversal Attacks in Node.js ; l’approche par nom généré rend toute cette classe d’attaques inatteignable.

Ré-encodez l’image ; ne stockez pas les octets d’origine

Le ré-encodage est le contrôle qui rend l’usurpation de signature sans effet. sharp (0.35.x) décode les pixels et écrit un fichier entièrement nouveau : ce qui avait été préfixé, suffixé ou dissimulé à l’intérieur de l’original n’y survit pas. Il colmate également une fuite de confidentialité : les photos de téléphone embarquent régulièrement des coordonnées GPS dans leurs données EXIF, et stocker les octets d’origine revient à republier la position de votre utilisateur. La documentation de sortie de sharp précise le comportement par défaut : rien des métadonnées de l’entrée n’atteint la sortie, sauf si vous les réclamez explicitement avec keepExif() ou withMetadata(). L’indicateur d’orientation part avec le reste : appliquez donc l’auto-orientation en premier avec .autoOrient(), sinon les photos de téléphone ressortent de travers.

let clean;
try {
  clean = await sharp(req.file.buffer)
    .autoOrient() // apply EXIF orientation before metadata is stripped
    .jpeg({ quality: 85 })
    .toBuffer();
} catch {
  return res.status(422).send("Not a decodable image"); // decode failure is a rejection
}

Un buffer qui a passé la vérification de signature mais échoue au décodage mentait sur son format. C’est la vérification qui fonctionne.

Diffusez ce que vous avez validé, pas ce que vous avez reçu

Lors de la diffusion des images stockées, définissez le Content-Type à partir de votre enregistrement de validation, jamais à partir de ce que le client a envoyé, ajoutez X-Content-Type-Options: nosniff pour que le navigateur ne puisse pas le remettre en question, et stockez les fichiers en dehors de la racine web afin que rien de ce qui est téléversé ne soit jamais directement exécutable ou affichable :

app.get("/images/:id", async (req, res) => {
  const record = await getImageRecord(req.params.id); // contentType saved at validation time
  if (!record) return res.sendStatus(404);
  res.setHeader("Content-Type", record.contentType);
  res.setHeader("X-Content-Type-Options", "nosniff");
  res.sendFile(record.storedName, { root: UPLOAD_DIR }); // UPLOAD_DIR is outside the web root
});

Quelques sujets connexes, en une ligne chacun :

  • L’analyse antivirus (ClamAV ou un équivalent cloud) est pertinente lorsque vous acceptez des documents arbitraires, pas des images ré-encodées.
  • Si vous acceptez un jour des archives, vérifiez la taille décompressée avant l’extraction ; les zip bombs sont de petits fichiers qui se dilatent énormément.
  • Le SVG est un document XML susceptible de transporter du script : excluez-le entièrement des endpoints d’images.
  • Les architectures à URL présignée téléversent directement vers le stockage objet, ce qui déplace tout ce pipeline dans une étape de traitement post-téléversement plutôt que de l’éliminer.

Pour conclure

Les cinq contrôles forment un seul pipeline : rejeter sur la taille au niveau du proxy, vérifier la signature en mémoire face à l’allowlist de cet endpoint, ré-encoder avec sharp, stocker sous un nom que vous avez généré, et diffuser avec des en-têtes que vous contrôlez.

ContrôleOù il s’exécuteCe qu’il bloque
Limite de tailleProxy d’abord, limits de Multer ensuite, code applicatif en dernierCorps de requête surdimensionnés consommant mémoire et bande passante
Vérification de signatureEn mémoire, avant que quoi que ce soit ne touche le disqueOctets non conformes à l’allowlist de cet endpoint
Ré-encodage avec sharpAprès validation, avant stockageSignatures falsifiées, charges utiles dissimulées, données GPS EXIF
Nom de fichier généréAu moment du stockageTraversée de chemin via le nom de fichier du client
En-têtes de diffusion validésÀ chaque lectureMIME sniffing et exécution depuis la racine web

La vérification de signature filtre à faible coût ; le ré-encodage est la frontière qui tient même lorsque la signature a été falsifiée. Commencez par examiner votre propre configuration Multer : si limits.fileSize n’est pas défini, cet endpoint accepte actuellement des fichiers de taille illimitée.

FAQ

Le package npm file-type remplace-t-il une vérification de signature écrite à la main ?

Il remplace la comparaison d'octets, pas le modèle de sécurité. file-type lit les mêmes nombres magiques, et son README est net sur ce que cela vous apporte : une correspondance est un indice, et elle ne tranche ni la question de savoir si le fichier est réellement de ce type, ni celle de savoir s'il est bien formé. Vous avez toujours besoin d'une allowlist spécifique à l'endpoint par-dessus, car il reconnaît des centaines de formats, et le ré-encodage reste la véritable frontière. Notez que le package est ESM uniquement : les projets CommonJS ont donc besoin d'un import dynamique ou du contournement load-esm.

La limite fileSize de Multer empêche-t-elle le client d'envoyer le reste du fichier ?

Pas de manière fiable. Lorsque limits.fileSize est atteint, Multer arrête la mise en tampon et lève une erreur LIMIT_FILE_SIZE, ce qui protège votre processus d'une consommation mémoire non bornée, mais rien dans la documentation ne garantit que le transfert réseau est annulé, et l'erreur peut ne survenir qu'après l'arrivée de tous les octets. Un plafond au niveau du proxy, tel que client_max_body_size de nginx, est ce qui protège réellement la bande passante, et c'est pourquoi la limite se place d'abord au niveau du proxy.

Comment valider les téléversements lorsque les clients utilisent des URL présignées pour téléverser directement vers le stockage objet ?

La validation se déplace vers une étape post-téléversement, elle ne disparaît pas. Le client téléverse dans un bucket ou un préfixe de quarantaine depuis lequel rien n'est diffusé, puis un worker en arrière-plan ou une fonction déclenchée par le stockage télécharge l'objet, exécute la même vérification de signature et le même ré-encodage sharp, et écrit la sortie propre à l'emplacement public sous un nom généré. Les objets qui échouent à la validation sont supprimés, et l'emplacement de quarantaine n'est jamais exposé aux navigateurs.

Le ré-encodage avec sharp modifie-t-il les couleurs de l'image ?

Cela peut arriver pour les images à gamut étendu. Sans intervention, sharp produit une sortie sRGB sans profil attaché, puisque la suppression des métadonnées emporte avec elle le profil ICC embarqué : les photos créées en Display P3 ou Adobe RGB peuvent donc légèrement dériver. Pour préserver la couleur sans réintroduire les données EXIF, appelez keepIccProfile() avant l'encodage. keepMetadata() conserve également le profil, mais ramène tout le reste, y compris les coordonnées GPS dont la suppression protégeait vos utilisateurs.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.