Pourquoi déchiffrer les blocs du buffer en texte perturbe les téléchargements de fichiers
Explique comment le traitement des données de tampon binaire comme du texte UTF-8 endommage silencieusement les fichiers téléchargés, et montre la manière correcte de gérer ces données au niveau des octets pour y remédier.
Un point d’entrée de téléchargement de fichiers peut réussir tous les tests manuels que l’on lui soumet. Les petites images, les PDF, les fichiers de texte brut : tout passe sans problème. Puis, sans avertissement, un client télécharge un fichier qui revient corrompu, une image avec des pixels erronés disséminés, ou un en-tête qui ne peut pas être interprété comme JSON alors qu’il était valide avant d’être envoyé depuis le client. Personne n’a touché au fichier pendant son transfert. La corruption s’est produite discrètement, à l’intérieur de code qui semble tout à fait logique au premier abord, et la cause racine est l’une des erreurs les plus fréquentes dans Node.js : traiter des données binaires comme s’il s’agissait de texte.
Qu’est-ce qu’un Buffer exactement ?
Buffer n’est rien de plus que le moyen dont dispose Node pour conserver une séquence d’octets bruts en mémoire. Il ne possède aucune signification intégrée ni aucun encodage de caractères, se contentant de valeurs numériques simples allant de 0 à 255 stockées de manière contiguë :
const buf = Buffer.from([72, 101, 108, 108, 111]);
console.log(buf); // <Buffer 48 65 6c 6c 6f>
console.log(buf.toString("utf8")); // "Hello"
Ces cinq valeurs en octets ne deviennent la chaîne lisible "Hello" que lorsque vous les interprétez délibérément à l’aide d’un encodage particulier, UTF-8 dans cet exemple. Seuls, ces octets ne constituent pas de texte ; ce ne sont que des octets. Un Buffer existe justement pour vous permettre de travailler avec des données binaires avant, ou complètement sans, décider qu’elles doivent être lues comme des caractères. C’est précisément ce fossé entre les « octets bruts » et le « texte selon un encodage choisi » qui est à l’origine de toute cette catégorie d’erreurs.
L’erreur : décoder des données binaires comme du texte
Le schéma qui provoque ce problème a un aspect trompeusement ordinaire :
app.post("/upload", (req, res) => {
let body = "";
req.on("data", (chunk) => {
body += chunk.toString("utf8"); // corrupting the file, one chunk at a time
});
req.on("end", () => {
fs.writeFileSync("upload.png", body, "utf8"); // and corrupting it again here
});
});
Les octets d’une image ne sont pas du texte. Ils forment un flux binaire arbitraire qui encode des valeurs de pixels, des tables de compression et des métadonnées, aucun de ces éléments n’étant conçu pour être lu comme des caractères UTF-8. L’appel à .toString("utf8") sur ce chargement binaire force le système d’exécution à interpréter des octets qui ne correspondent souvent à aucune séquence UTF-8 valide. Plutôt que de générer une erreur, le décodeur remplace silencieusement par le caractère de remplacement Unicode (, U+FFFD) tout endroit où il rencontre un motif d’octets qu’il ne peut pas déchiffrer. Ces octets originaux disparaissent définitivement, remplacés par un symbole de remplacement sans possibilité de retrouver la valeur initiale. C’est précisément pour cette raison que la corruption résultante semble dispersée et aléatoire : seules les séquences d’octets qui ne sont pas des UTF-8 valides sont altérées, et pour les formats binaires tels que les images, cela se produit constamment.
app.post("/upload", (req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk)); // keep raw bytes, don't decode anything
req.on("end", () => {
const fileBuffer = Buffer.concat(chunks);
fs.writeFileSync("upload.png", fileBuffer); // write raw bytes, no string conversion involved
});
});
En supposant que le corps de la requête contient directement les octets bruts du fichier plutôt qu’un en-tête multipart/form-data, cette approche conserve le fichier exactement tel qu’il a été téléchargé. Si vous travaillez avec des téléversements multipart, faites d’abord passer les données par un analyseur multipart approprié pour extraire la partie correspondant au fichier. La solution de base est simple : ne convertissez jamais de données binaires en chaîne de caractères. Au lieu de cela, collectez les fragments Buffer reçus tels quels, assemblez-les au niveau des octets, puis écrivez ces octets directement sur le disque ou dans le stockage.
Le même bug, plus discret et insidieux : les caractères multibytes séparés entre les fragments
Même lorsque vous travaillez réellement avec du texte, décoder les données par fragments plutôt que d’un seul coup ouvre la porte à une variante similaire mais plus subtile de ce bug, particulièrement pertinente si vous traitez des données en flux de manière incrémentale :
readStream.on("data", (chunk) => {
process.stdout.write(chunk.toString("utf8")); // can corrupt multi-byte characters
});
Un seul emoji ou lettre accentuée peut occuper plusieurs octets en UTF-8, et les limites des blocs provenant d’une connexion réseau ou d’un flux de fichiers ne permettent pas de déterminer où se situent ces limites multi-octets. Si un bloc s’arrête par hasard au milieu d’un caractère, la décodage de ce bloc isolément produit un caractère corrompu, remplacé silencieusement par un caractère de remplacement, bien que la séquence d’octets complète et correcte ait toujours été présente, simplement divisée en deux appels distincts de .toString(), chacun ne voyant que la moitié de celle-ci.
const decoder = new (require("string_decoder").StringDecoder)("utf8");
readStream.on("data", (chunk) => {
process.stdout.write(decoder.write(chunk)); // holds incomplete multi-byte sequences until the rest arrives
});
readStream.on("end", () => {
process.stdout.write(decoder.end());
});
Le StringDecoder intégré de Node est conçu précisément pour cette situation : il retient toute séquence multibyte incomplète se trouvant à la fin d’un chunk au lieu de la décoder prématurément, et attend que les octets manquants apparaissent dans le chunk suivant avant de finaliser le caractère. Il s’agit d’une solution distincte de la concaténation de buffers avec Buffer.concat, adaptée spécifiquement lorsque l’on doit décoder du texte de manière sécurisée au fur et à mesure de son afflux, plutôt que de stocker tout un fichier binaire avant d’effectuer toute conversion.
Incohérences de codage : écriture avec un codage, lecture avec un autre
Il existe une erreur similaire tout aussi discrète : le choix de codages incompatibles du côté de l’écriture par rapport à celui de la lecture d’une opération.
const token = crypto.randomBytes(32); // raw binary
const encoded = token.toString("base64"); // encode once, deliberately, for safe transport
// later, elsewhere in the codebase
const decoded = Buffer.from(encoded, "hex"); // wrong encoding — does not recover the original bytes
Buffer.from lit la chaîne en fonction de l’argument d’encodage que vous lui passez. Si cet encodage n’est pas celui qui a réellement été utilisé pour créer la chaîne à l’origine, vous ne récupérerez pas les octets originaux de manière fiable. Selon l’encodage utilisé et la forme des données d’entrée, Node peut décoder des valeurs de octets complètement différentes, ou supprimer silencieusement les parties de la chaîne qui ne respectent pas les règles de cet encodage, au lieu de lever une erreur que vous remarqueriez immédiatement.
Buffer.alloc vs Buffer.allocUnsafe : une différence liée à la sécurité, et non seulement à la performance
Il existe une autre distinction importante à bien comprendre, car une erreur dans son utilisation n’est pas seulement un bug : elle peut entraîner une fuite de données sensibles.
const safeBuf = Buffer.alloc(16); // zero-filled, always
const fastBuf = Buffer.allocUnsafe(16); // NOT zero-filled — may contain old memory contents
Buffer.allocUnsafe omet l’étape de zérosage de la mémoire qu’il vous fournit, ce qui est effectivement plus rapide, mais cela signifie que le buffer peut encore conserver les octets qui se trouvaient précédemment dans cette région de mémoire, tels qu’un reste d’une requête antérieure, un fragment du token de session d’un autre utilisateur, ou tout autre élément y ayant été stocké auparavant. Si vous allouez un buffer de cette manière et n’écrivez que une partie de ses contenus avant de les envoyer, que ce soit via une connexion réseau ou dans un fichier sur disque, vous risquez de divulguer des données sans rapport avec l’opération en cours. Buffer.alloc implique un coût faible et prévisible pour zéroser la mémoire au préalable, et c’est ce que vous devez utiliser par défaut. N’optez pour allocUnsafe que dans le cas très précis où vous êtes certain de réécrire l’intégralité du buffer vous-même avant que quoi que ce soit d’autre ne le modifie.
La leçon réelle
Chacune de ces défaillances revient à une confusion fondamentale : traiter une séquence brute de octets comme s’il s’agissait naturellement de texte, alors que en réalité le « texte » n’existe que lorsque l’on a pris délibérément la décision de savoir quel encodage utiliser pour interpréter ces octets, et que cette décision peut être appliquée de manière incorrecte, trop tôt ou au mauvais moment dans le processus. L’approche la plus sûre consiste à conserver les données binaires en tant que telles le plus longtemps possible, en les concaténant et en les transformant en tant que simples octets bruts, et à ne les convertir en chaîne de caractères qu’au moment précis où quelque chose a réellement besoin d’elles en tant que texte, en utilisant à ce moment-là l’encodage correct et explicitement choisi. Sans cette discipline, la corruption ne se manifeste pas comme une erreur évidente ; elle remplace simplement silencieusement par n’importe quels octets qu’elle ne peut pas interpréter, et l’on découvre le problème plus tard, généralement parce qu’un utilisateur signale que quelque chose ne fonctionne pas.
.Lectures complémentaires
- Stratégie de token de réapprovisionnement pour les systèmes d’authentification Node.js — Découvrez comment concevoir, rotationner, révoquer et stocker de manière sécurisée les tokens de réapprovisionnement dans Node.js afin que le vol de tokens et la déconnexion fonctionnent comme prévu.
- Structurer les services Node.js avec des modules dirigés par le domaine et des couches propres — Apprenez à organiser une base de code Node.js en composants basés sur le domaine, à appliquer une architecture stricte à 3 niveaux et à exposer des utilitaires partagés via des API publiques propres.