Чому декодування фрагментів буфера як тексту заважає завантаженню файлів
Пояснює, як обробка даних бінарної буферної пам’яті як тексту UTF-8 тихо пошкоджує завантажені файли, та показує правильний спосіб обробки на рівні байтів для запобігання цьому.
Ендпоїнт для завантаження файлів може пройти кожен ручний тест, який ви йому запропонуєте. Маленькі зображення, PDF-файли, файли простого тексту — усе проходить без проблем. А потім, без жодних попереджень, клієнт завантажує файл, який повертається пошкодженим: зображення з розсіяними неправильними пікселями чи дані, які не можна розпарсити як JSON, хоча раніше вони були коректними ще до того, як покинули клієнта. Ніхто не торкався файлу під час передачі. Пошкодження виникло тихо, у коді, який на перший погляд здається цілком логічним, а кореневою причиною є одна з найпоширеніших помилок у Node.js — сприйняття бінарних даних як тексту.
Що таке Buffer
Buffer — це просто спосіб Node зберігати послідовність сирих байтів у пам’яті. Він не має вбудованого значення та жодного кодування символів, це просто числові значення від 0 до 255, збережені послідовно:
const buf = Buffer.from([72, 101, 108, 108, 111]);
console.log(buf); // <Buffer 48 65 6c 6c 6f>
console.log(buf.toString("utf8")); // "Hello"
Ці п’ять значень байтів перетворюються на читабельний рядок "Hello" лише тоді, коли ви навмисно інтерпретуєте їх за допомогою певної кодування — у цьому прикладі UTF-8. Самі по собі байти не є текстом; це просто байти. Об’єкт Buffer існує саме для того, щоб дозволити працювати з бінарними даними до того, як вирішити, чи їх потрібно читати як символи, або взагалі без такого рішення. Саме ця прогалина між „сирими байтами“ та „текстом у обраному кодуванні“ є джерелом усіх цих типів помилок.
Помилка: декодування бінарних даних як тексту
Схема, яка спричиняє цю проблему, виглядає на перший погляд досить звичайною:
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
});
});
Байти зображення — це не текст. Вони утворюють довільний бінарний потік, який кодує значення пікселів, таблиці стиснення та метадані; жоден з цих елементів не призначений для читання як символи UTF-8. Виклик методу .toString("utf8") для цього бінарного даних змушує систему обробки програми інтерпретувати байти, які часто не відповідають жодній дійсній послідовності UTF-8. Замість того, щоб викинути помилку, декодер тихо замінює їх на символ-замінник Unicode (, U+FFFD) там, де зустрічається шаблон байтів, який не може декодувати. Ці первинні байти назавжди зникають, замінюючись місцевим замінником, без можливості повернутися до початкового значення. Саме тому пошкодження, які виникають, здаються розсіяними та випадковими: пошкоджуються лише ті послідовності байтів, які не є дійсними UTF-8, а для бінарних форматів, таких як зображення, це відбувається постійно.
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
});
});
Якщо припустити, що тіло запиту містить безпосередньо сирі байти файлу, а не вміст у форматі multipart/form-data, цей підхід зберігає файл точно таким, як він був завантажений. Якщо ви працюєте з багаточастинними завантаженнями, спочатку обробіть дані за допомогою належного парсера multipart, щоб витягнути частину з файлом. Основне рішення просте: ніколи не перетворюйте бінарні дані на рядок. Натомість збирайте надходжені частини типу Buffer у такому вигляді, як вони є, об’єднуйте їх на рівні байтів та записуйте ці байти безпосередньо на диск або в сховище.
Та сама помилка, але менш помітна: багатобайтові символи, розділені на частини
Навіть коли ви справді працюєте з текстом, декодування частин по черзі, а не всього одразу, створює можливість для схожої, але більш тонкої форми цієї помилки, що особливо актуально при поступовій обробці потокових даних:
readStream.on("data", (chunk) => {
process.stdout.write(chunk.toString("utf8")); // can corrupt multi-byte characters
});
Один емодзі чи літера з акцентом у форматі UTF-8 може займати кілька байтів, і межі блоків даних від мережевого з’єднання чи потоку файлів не мають уявлення про те, де саме знаходяться ці багатобайтові межі. Якщо блок даних випадково закінчується посеред символа, його декодування окремо призводить до пошкодженого символу, який тихо замінюється на символ-замінник, хоча повна та правильна послідовність байтів завжди була присутня, просто розділена між двома окремими викликами методу .toString(), кожен з яких бачив лише половину цієї послідовності.
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());
});
Вбудований у Node StringDecoder розроблений саме для такої ситуації: він утримує будь-яку неповну багатобайтову послідовність, яка знаходиться в кінці блоку даних, замість того щоб розшифрувати її занадто рано, і чекає на появу залишкових байт у наступному блоку, перш ніж завершити обробку символа. Це принципово інше рішення, ніж об’єднання буферів за допомогою Buffer.concat, і воно застосовується саме тоді, коли потрібно безпечно розшифровувати текст у момент його надходження, а не зберігати весь бінарний файл у буфері перед будь-якою конвертацією.
Несумісності кодування: запис у одному форматі, читання в іншому
Існує ще одна подібна проблема, яка проявляється так само непомітно: вибір несумісних форматів кодування під час запису та читання даних.
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 читає рядок відповідно до аргументу кодування, який ви передаєте. Якщо це кодування не є тим, яке насправді використовувалося для створення рядка спочатку, ви не зможете отримати оригінальні байти надійним способом. Залежно від типу кодування та форми вхідних даних, Node може розкодувати зовсім інші значення байтів або мовчки видалити ті частини рядка, які не підходять під правила цього кодування, замість того щоб викинути помилку, яку можна було б одразу помітити.
Buffer.alloc проти Buffer.allocUnsafe: різниця, що стосується безпеки, а не лише продуктивності
Є ще одна відмінність, яку варто зрозуміти, і вона має значення саме тут, тому що її неправильне застосування — це не просто баг, а й потенційна витока конфіденційних даних:
const safeBuf = Buffer.alloc(16); // zero-filled, always
const fastBuf = Buffer.allocUnsafe(16); // NOT zero-filled — may contain old memory contents
Buffer.allocUnsafe пропускає крок нулювання пам’яті, яку він передає, що справді робить процес швидшим, але це означає, що буфер все одно може містити будь-які байти, які раніше знаходилися в цій області пам’яті — можливо, залишки попереднього запиту, фрагмент токена сесії іншого користувача чи щось інше, що там раніше зберігалося. Якщо ви виділяєте буфер таким чином та записуєте в нього лише частину даних перед тим, як надіслати його, чи то через мережеве з’єднання, чи до файлу на диску, ви ризикуєте оприлюднити дані, які не мають жодного відношення до поточної операції. Buffer.alloc передбачає невеликі, прогнозовані витрати на попереднє нулювання пам’яті, і саме цей варіант має бути вашим стандартним вибором. Використовуйте allocUnsafe лише у виняткових випадках, коли ви точно будете самі перезаписувати весь буфер перед тим, як хтось інший зможе до нього дістатися.
Справжній урок
Кожна з цих проблем походить від однієї основної плутанини: сприйняття сукупності байтів як тексту, хоча насправді „текст“ існує лише тоді, коли ви усвідомлено вирішуєте, яке кодування використати для його інтерпретації, причому це рішення може бути прийняте неправильно, занадто рано чи у невідповідний момент у процесі обробки. Безпечніший підхід — залишати бінарні дані бінарними якомога довше, об’єднуючи та перетворюючи їх як сукупність байтів, а перетворювати їх у рядок лише тоді, коли це справді необхідно як текст, використовуючи для цього правильне, спеціально обране кодування. Без такої дисципліни пошкодження не проявляються як очевидна помилка — вони просто тихо замінюють ті байти, які не вдалося інтерпретувати, і проблему доводиться виявляти пізніше, зазвичай через повідомлення користувача про те, що щось не працює.
Пов’язана література
- Стратегія оновлення токенів для систем автентифікації Node.js — Дізнайтеся, як проектувати, оновлювати, скасовувати та безпечно зберігати токени оновлення в Node.js, щоб крадіжка токенів та вихід з системи функціонували саме так, як очікується.
- Структурування сервісів Node.js за допомогою модулів, заснованих на концепції домену, та чистих шарів — Дізнайтеся, як організувати кодову базу Node.js у компоненти, засновані на концепції домену, запровадити сувору тришарову архітектуру та забезпечити доступ до спільних функцій через чисті публічні API.