Strona główna / Artykuły / Dlaczego dekodowanie fragmentów bufora jako tekstu psuje przesyłanie plików

Dlaczego dekodowanie fragmentów bufora jako tekstu psuje przesyłanie plików

Wyjaśnia, w jaki sposób traktowanie danych z bufora binarnego jako tekstu UTF-8 w tajemnicy uszkadza przesłane pliki, oraz pokazuje właściwe sposoby obsługi na poziomie bajtów, aby temu zapobiec.

1375 słów

Interfejs do przesyłania plików może przejść każdą ręczną próbę testowania, jaką mu poddamy. Małe obrazy, pliki PDF, pliki tekstowe – wszystko przechodzi bez problemów. Nagle jednak klient przesyła plik, który wraca uszkodzony: obraz z rozproszonymi błędnymi pikselami lub dane, które nie dają się zinterpretować jako JSON, mimo że były poprawne przed opuszczeniem klienta. Nikt nie ingerował w plik w trakcie transmisji. Uszkodzenie nastąpiło po cichu, w kodzie, który na pierwszy rzut oka wydaje się całkowicie sensowny, a przyczyną jest jeden z najczęstszych błędów w Node.js: traktowanie danych binarnych jak tekstu.

Czym właściwie jest Buffer

Buffer to po prostu sposób, w jaki Node przechowuje sekwencję surowych bajtów w pamięci. Nie ma on żadnego wbudowanego znaczenia ani kodowania znaków – to zwykłe wartości liczbowe od 0 do 255 przechowywane kolejno:

const buf = Buffer.from([72, 101, 108, 108, 111]);
console.log(buf); // <Buffer 48 65 6c 6c 6f>
console.log(buf.toString("utf8")); // "Hello"

Te pięć wartości bajtowych staje się czytelnym łańcuchem "Hello" dopiero wtedy, gdy celowo interpretujesz je przy użyciu określonego kodowania – w tym przykładzie UTF-8. Same w sobie bajty nie są tekstem; to po prostu bajty, a Buffer służy właśnie do pracy z danymi binarnymi, zarówno przed podjęciem decyzji o ich odczytaniu jako znaków, jak i bez niej. To rozdzielenie między „surowymi bajtami” a „tekstem w wybranym kodowaniu” jest dokładnie źródłem całej tej grupy błędów.

Błąd: Dekodowanie danych binarnych jako tekstu

Wzorzec, który powoduje ten problem, wygląda zwodniczo zwyczajnie:

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
  });
});

Bajty obrazu nie są tekstem. Tworzą one dowolny strumień binarny, który koduje wartości pikseli, tabele kompresji oraz metadane – żaden z tych elementów nie został stworzony po to, by być odczytywany jako znaki UTF-8. Wywołanie metody .toString("utf8") na tym danych binarnych zmusza system do interpretacji bajtów, które często nie odpowiadają żadnej ważnej sekwencji UTF-8. Zamiast rzucać błąd, dekoder cicho zastępuje je znakiem zamiennym Unicode (, U+FFFD) w miejscach, gdzie napotyka wzorzec bajtów, którego nie potrafi zdekodować. Te oryginalne bajty znikają na dobre, zastępowane przez znacznik tymczasowy, który nie umożliwia przywrócenia pierwotnej wartości. Właśnie dlatego uszkodzenia wyglądają na rozproszone i losowe: tylko te sekwencje bajtów, które nie są ważnymi sekwencjami UTF-8, ulegają zniekształceniu, a w przypadku formatów binarnych takich jak obrazy dzieje się to ciągle.

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
  });
});

Przy założeniu, że ciało żądania zawiera bezpośrednio surowe bajty pliku, a nie treść typu multipart/form-data, ten sposób zachowuje plik dokładnie takim, jak został przesłany. Jeśli masz do czynienia z plikami przesylanymi w formacie multipart, najpierw przepuść dane przez odpowiedni parser typu multipart, aby wyodrębnić część odpowiadającą plikowi. Podstawowe rozwiązanie jest proste: w ogóle nie konwertuj danych binarnych na ciąg znaków. Zamiast tego zbieraj przychodzące fragmenty typu Buffer w takim stanie, w jakim są, łącz je na poziomie bajtów i zapisuj te bajty bezpośrednio na dysku lub w systemie przechowywania.

Ten sam błąd, mniejszy i bardziej podstępny: znaki wielobajtowe rozdzielone między fragmentami

Nawet gdy faktycznie pracujesz z tekstem, dekodowanie fragment po fragmencie zamiast wszystkiego naraz otwiera drogę do powiązanego, lecz subtelniejszego wariantu tego błędu, szczególnie istotnego przy przetwarzaniu danych strumieniowych krok po kroku:

readStream.on("data", (chunk) => {
  process.stdout.write(chunk.toString("utf8")); // can corrupt multi-byte characters
});

Jedno emoji lub litera z akcentem może zajmować kilka bajtów w formacie UTF-8, a granice fragmentów z połączenia sieciowego lub strumienia pliku nie wiedzą, gdzie dokładnie znajdują się te wielobajtowe granice. Jeśli fragment kończy się akurat w środku znaku, dekodowanie tego fragmentu osobno skutkuje uszkodzonym znakiem, który jest cicho zastępowany innym znakiem, mimo że cała, poprawna sekwencja bajtów była od początku obecna, tylko rozdzielona pomiędzy dwoma oddzielnymi wywołaniami .toString(), z których każde widziało tylko połowę.

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());
});

Wbudowany w Node StringDecoder został stworzony właśnie do takich sytuacji: zatrzymuje wszelkie niekompletne sekwencje wielokrotnych bajtów znajdujące się na końcu fragmentu danych, zamiast dekodować je zbyt wcześnie, i czeka na pojawienie się pozostałych bajtów w następnym fragmencie, zanim zakończy dekodację znaku. Jest to rozwiązanie odmienne od łączenia buforów za pomocą Buffer.concat, stosowane konkretnie wtedy, gdy potrzebna jest bezpieczna dekodacja tekstu w trakcie jego przepływu, zamiast buforowania całego pliku binarnego przed jakąkolwiek konwersją.

Niespójności kodowania: zapis jednego kodowania, odczyt innego

Istnieje powiązany problem, który jest równie niepostrzeżony: wybór niespójnych kodowań po stronie zapisu w porównaniu ze stroną odczytu operacji.

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 odczytuje ciąg znaków zgodnie z argumentem kodowania, który mu podasz. Jeśli ten kodowanie nie jest tym samym, które faktycznie zostało użyte do utworzenia ciągu znaków, nie uda się w żaden wiarygodny sposób odzyskać oryginalnych bajtów. W zależności od użytego kodowania i wyglądu danych wejściowych, Node może zdekodować zupełnie inne wartości bajtowe lub po cichu pominąć te części ciągu znaków, które nie pasują do reguł danego kodowania, zamiast rzucić błąd, który natychmiast byś zauważył.

Buffer.alloc vs Buffer.allocUnsafe: Różnica istotna dla bezpieczeństwa, a nie tylko wydajności

Istnieje jeszcze jedna różnica, którą warto zapamiętać – ma ona tu szczególne znaczenie, ponieważ błąd w jej zastosowaniu to nie tylko błąd programowy, ale także potencjalne wycieki danych poufnych:

const safeBuf = Buffer.alloc(16);       // zero-filled, always
const fastBuf = Buffer.allocUnsafe(16); // NOT zero-filled — may contain old memory contents

Buffer.allocUnsafe pomija krok zerowania pamięci, którą przekazuje, co faktycznie sprawia, że jest szybszy, ale oznacza to, że bufor może nadal zawierać dowolne bajty, które wcześniej znajdowały się w tej części pamięci – być to resztki wcześniejszej prośby, fragment tokena sesji innego użytkownika lub cokolwiek innego, co zostało tam wcześniej zapisane. Jeśli w ten sposób przydzelasz bufor i zapiszesz w nim tylko część danych przed ich wysłaniem, czy to przez połączenie sieciowe, czy do pliku na dysku, narażasz się na ujawnienie informacji, które nie mają żadnego związku z bieżącą operacją. Buffer.alloc wiąże się z niewielkim, przewidywalnym kosztem w postaci uprzedniego zerowania pamięci, i to powinno być twoim standardowym wyborem. Używaj allocUnsafe tylko w wyjątkowych przypadkach, gdy jesteś pewien, że sam przepiszesz cały bufor, zanim cokolwiek innego go dotknie.

Rzeczywista lekcja

Każdy z tych błędów wynika z jednego podstawowego nieporozumienia: traktowania surowej sekwencji bajtów tak, jakby była naturalnie tekstem, podczas gdy w rzeczywistości „tekst” istnieje dopiero po podjęciu świadomej decyzji o tym, jakie kodowanie użyć do interpretacji tych bajtów, a decyzja ta może zostać podjęta błędnie, zbyt wcześnie lub w niewłaściwym momencie procesu. Bezpieczniejszym podejściem jest utrzymywanie danych binarnych w formie binarnej tak długo, jak to możliwe, łącząc je i przekształcając jako surowe bajty, oraz konwertowanie ich na ciąg znaków dopiero w momencie, gdy faktycznie jest to potrzebne jako tekst, używając przy tym właściwego, wyraźnie wybranego kodowania. Bez tej dyscypliny uszkodzenia nie objawiają się jako oczywisty błąd – po prostu cicho zastępują one dowolne bajty, których nie mogły zinterpretować, a problem odkrywa się później, zwykle dlatego, że użytkownik zgłosił, iż coś nie działa.

.

Literatura pokrewna