Por qué decodificar los fragmentos del búfer como texto interrumpe las subidas de archivos
Explica cómo tratar los datos del búfer binario como texto UTF-8 corrompe silenciosamente los archivos subidos y muestra el manejo correcto a nivel de bytes para evitarlo.
Un punto de conexión para subir archivos podría superar todas las pruebas manuales que se le apliquen. Imágenes pequeñas, PDFs, archivos de texto plano: todo pasa sin problemas. De repente, sin previo aviso, un cliente sube un archivo que llega dañado, una imagen con píxeles incorrectos dispersos, o un contenido que no se puede parsear como JSON aunque era válido antes de salir del cliente. Nadie tocó el archivo durante su transmisión. El daño ocurrió silenciosamente, dentro de código que a primera vista parece completamente razonable, y la causa raíz es uno de los errores más frecuentes en Node.js: tratar datos binarios como si fueran texto.
Qué es realmente un Buffer
Un Buffer es simplemente la forma en que Node almacena una secuencia de bytes sin procesar en memoria. No tiene ningún significado inherente ni codificación de caracteres; solo valores numéricos simples entre 0 y 255 almacenados de forma contigua:
const buf = Buffer.from([72, 101, 108, 108, 111]);
console.log(buf); // <Buffer 48 65 6c 6c 6f>
console.log(buf.toString("utf8")); // "Hello"
Esos cinco valores de byte solo se convierten en la cadena legible "Hello" cuando se interpretan deliberadamente usando un código de codificación específico, UTF-8 en este ejemplo. Por sí solos, los bytes no son texto; son simplemente bytes. Un Buffer existe precisamente para permitir trabajar con datos binarios antes, o incluso sin decidir si deben leerse como caracteres. Esa brecha entre los “bytes en bruto” y el “texto bajo un código de codificación elegido” es exactamente donde surge toda esta clase de errores.
El error: Decodificar datos binarios como texto
El patrón que desencadena este problema tiene un aspecto engañosamente común:
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
});
});
Los bytes de una imagen no son texto. Forman un flujo binario arbitrario que codifica valores de píxeles, tablas de compresión y metadatos, ninguno de los cuales estaba destinado a ser leído como caracteres UTF-8. Llamar a .toString("utf8") sobre ese payload binario obliga al tiempo de ejecución a interpretar bytes que con frecuencia no corresponden a ninguna secuencia UTF-8 válida. En lugar de lanzar un error, el decodificador reemplaza silenciosamente esos bytes por el carácter de reemplazo Unicode (, U+FFFD) cada vez que se encuentra con un patrón de bytes que no puede decodificar. Esos bytes originales desaparecen para siempre, siendo reemplazados por un marcador de posición del que no hay forma de recuperar el valor original. Esta es precisamente la razón por la cual la corrupción resultante parece dispersa y aleatoria: solo las secuencias de bytes que no son válidas UTF-8 se dañan, y en formatos binarios como las imágenes esto ocurre constantemente.
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
});
});
Suponiendo que el cuerpo de la solicitud contiene directamente los bytes brutos del archivo en lugar de un payload multipart/form-data, este enfoque conserva el archivo tal como se subió. Si estás manejando cargas multipart, primero pasa los datos por un analizador multipart adecuado para extraer la parte del archivo. La solución es sencilla: nunca conviertas los datos binarios en cadena de texto. En su lugar, recopila los fragmentos Buffer recibidos tal como están, únelos a nivel de bytes y escribe esos bytes directamente en el disco o almacenamiento.
El mismo error, más pequeño y sutil: caracteres multibyte divididos en fragmentos
Incluso cuando realmente estás trabajando con texto, decodificar fragmento por fragmento en lugar de hacerlo todo a la vez abre la puerta a una variante relacionada pero más sutil de este error, especialmente relevante si estás procesando datos en streaming de forma incremental:
readStream.on("data", (chunk) => {
process.stdout.write(chunk.toString("utf8")); // can corrupt multi-byte characters
});
Un único emoji o letra acentuada puede ocupar varios bytes en UTF-8, y los límites de los bloques provenientes de una conexión de red o flujo de archivo no tienen forma de saber dónde se encuentran esos límites de varios bytes. Si un bloque termina casualmente a mitad de carácter, decodificar ese bloque por separado genera un carácter corrupto, que es reemplazado silenciosamente por otro carácter, aunque la secuencia de bytes completa y correcta estuvo presente todo el tiempo, simplemente dividida entre dos llamadas separadas a .toString(), cada una de las cuales solo vio la mitad.
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());
});
El StringDecoder integrado en Node está diseñado exactamente para esta situación: evita decodificar cualquier secuencia multi-byte incompleta que se encuentre al final de un bloque, en lugar de hacerlo demasiado pronto, y espera a que los bytes restantes aparezcan en el siguiente bloque antes de finalizar la decodificación del carácter. Esta es una solución distinta a la concatenación de buffers con Buffer.concat; se aplica específicamente cuando es necesario decodificar texto de forma segura a medida que llega, en lugar de almacenar todo un archivo binario en búfer antes de realizar cualquier conversión.
Inconsistencias en la codificación: escribir con un formato y leer con otro
Existe un fallo relacionado que es igualmente silencioso: elegir formatos de codificación incompatibles en el lado de escritura frente al lado de lectura de una operación.
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 lee la cadena según el argumento de codificación que se le proporcione. Si esa codificación no es la misma que se utilizó originalmente para generar la cadena, no será posible recuperar los bytes originales de manera fiable. Dependiendo de la codificación empleada y de la estructura de la entrada, Node podría decodificar valores de bytes completamente diferentes o omitir silenciosamente las partes de la cadena que no se ajustan a las reglas de dicha codificación, en lugar de generar un error que se detectaría de inmediato.
Buffer.alloc vs Buffer.allocUnsafe: Una diferencia relevante para la seguridad, no solo de rendimiento
Hay otra distinción que vale la pena conocer, y es importante aquí específicamente porque cometer un error al respecto no solo constituye un bug, sino también una posible fuga de datos sensibles:
const safeBuf = Buffer.alloc(16); // zero-filled, always
const fastBuf = Buffer.allocUnsafe(16); // NOT zero-filled — may contain old memory contents
Buffer.allocUnsafe omite el paso de zerar la memoria que le entrega, lo cual es realmente más rápido, pero eso significa que el buffer aún puede contener los bytes que estaban almacenados anteriormente en esa región de memoria, posiblemente restos de una solicitud anterior, un fragmento del token de sesión de otro usuario o cualquier otra cosa que se haya almacenado allí antes. Si asignas un buffer de esta manera y solo escribes parte de él antes de enviarlo, ya sea a través de una conexión de red o a un archivo en el disco, corres el riesgo de exponer datos que no tienen relación con la operación actual. Buffer.alloc implica un costo pequeño y predecible al zerar la memoria de antemano, y ese debería ser tu opción por defecto. Solo utiliza allocUnsafe en los casos muy específicos en los que estés seguro de que sobrescribirás todo el buffer tú mismo antes de que nada más lo modifique.
La lección real
Cada uno de estos fallos se debe a una misma confusión subyacente: tratar una secuencia bruta de bytes como si fuera naturalmente texto, cuando en realidad el “texto” solo existe una vez que se ha tomado la decisión deliberada sobre qué codificación utilizar para interpretar esos bytes, y esa decisión puede aplicarse incorrectamente, demasiado pronto o en el momento equivocado del proceso. El enfoque más seguro es mantener los datos binarios como tales el mayor tiempo posible, concatenándolos y transformándolos como bytes brutos, y convertirlos en cadena de texto únicamente en el momento específico en que realmente se necesiten como tal, utilizando la codificación correcta y elegida explícitamente en ese instante. Sin esa disciplina, la corrupción no se manifiesta como un error evidente; simplemente sustituye silenciosamente por los bytes que no pudo interpretar, y uno descubre el problema más tarde, generalmente porque un usuario informó que algo no funcionaba.
.Lecturas relacionadas
- Estrategia de token de refresco para sistemas de autenticación en Node.js — Aprenda cómo diseñar, rotar, revocar y almacenar de forma segura los tokens de refresco en Node.js para que el robo de tokens y el cierre de sesión funcionen tal como se espera.
- Estructurando servicios en Node.js con módulos gestionados por el dominio y capas limpias — Aprenda cómo organizar una base de código en Node.js en componentes basados en el dominio, aplicar una arquitectura estricta de 3 capas y exponer utilidades compartidas a través de APIs públicas limpias.