Чаму дэкодаванне фрагментаў буфера як тексту спакшвае аплоад файлаў
Пасвячаецца таму, як обработка дадзейна бінарнага буфера як текста 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. У заместо таго, каб выклікнуць адказку, декодэр тыхо заменяе байты на симвал замены Юнікода (, 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, такы падход захоўвае файл абсалютна такім, які быў заванесены. Якщо вы працуеце з мнагаблочным заваносам, спачатку перадайце даныя через правільны парсер мнагаблочных дадзеных, каб выявіць частку з файлам. Фундаментальны спосаб выправлення прост: ніколы не ператвараць бінарныя даныя на строку. У замене скупайце прыходзячыя часткі 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());
});
Вбудованы StringDecoder у класа Node створаны самэй для такой ситуацыі: яны не дазволяюць адкодаваць непূраныя багатобайтовыя послідоўнасці, якія знаходзяцца напрыканцы часткі дадзеных, а чакаюць, пакуль засталыя байты з’явяцца ў наступной часткі, перш чым завершыць адкодавання символа. эта ўнікальная метода, аднародная з кэталогаваннем буфероў за дапамогою 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.