Startseite / Artikel / Warum das Dekodieren von Pufferblöcken als Text die Datei hochladen verhindert

Warum das Dekodieren von Pufferblöcken als Text die Datei hochladen verhindert

Erklärt, wie das Behandeln von Binärpufferdaten als UTF-8-Text heimlich hochgeladene Dateien beschädigt, und zeigt die richtige Verarbeitung auf Byteebene, um dies zu verhindern.

1375 Wörter

Ein Endpunkt für das Hochladen von Dateien besteht vielleicht alle manuellen Tests, die man ihm vorlegt. Kleine Bilder, PDFs, Textdateien – alles funktioniert einwandfrei. Plötzlich, ohne Vorwarnung, lädt ein Kunde eine Datei hoch, die beschädigt zurückkommt: ein Bild mit falschen Pixeln oder eine Datei, die nicht als JSON interpretiert werden kann, obwohl sie vor dem Verlassen des Clients noch gültig war. Niemand hat die Datei während der Übertragung verändert. Der Schaden entstand still und heimlich innerhalb von Code, der auf den ersten Blick völlig vernünftig erscheint, und die Ursache ist einer der häufigsten Fehler in Node.js: das Behandeln binärer Daten, als wären es Texte.

Was ein Buffer eigentlich ist

Ein Buffer ist einfach die Art und Weise, wie Node eine Abfolge von Rohbytes im Speicher speichert. Er besitzt keine eingebauten Bedeutungen oder Zeichencodierungen, sondern lediglich reine numerische Werte zwischen 0 und 255, die kontinuierlich gespeichert werden:

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

Die fünf Byte-Werte werden erst dann zur lesbaren Zeichenkette "Hello", wenn man sie absichtlich mit einer bestimmten Kodierung interpretiert – in diesem Beispiel UTF-8. Für sich genommen sind die Bytes kein Text; sie sind lediglich Bytes. Ein Buffer dient genau dazu, mit binären Daten arbeiten zu können, bevor oder ganz ohne Entscheidung darüber, ob sie als Zeichen gelesen werden sollen. Genau diese Lücke zwischen „rohen Bytes“ und „Text unter einer gewählten Kodierung“ ist die Ursache für diese ganze Klasse von Fehlern.

Der Fehler: Binäre Daten als Text decodieren

Muster, die dieses Problem auslösen, sehen täuschend gewöhnlich aus:

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

Bild-Bytes sind kein Text. Sie bilden einen willkürlichen Binärstrom, der Pixelwerte, Komprimierungstabellen und Metadaten kodiert – wobei keines davon ursprünglich dazu bestimmt war, als UTF-8-Zeichen gelesen zu werden. Der Aufruf von .toString("utf8") an diesem Binärdatenstrom zwingt die Laufzeitumgebung, Bytes zu interpretieren, die häufig nicht mit einer gültigen UTF-8-Sequenz übereinstimmen. Anstatt einen Fehler auszulösen, ersetzt der Dekoder stillschweigend jedes nicht decodierbare Byte-Muster durch das Unicode-Ersatzzeichen (, U+FFFD). Die ursprünglichen Bytes sind somit endgültig verschwunden und durch einen Platzhalter ersetzt worden, über den kein Weg mehr zum ursprünglichen Wert führt. Genau deshalb wirkt die entstehende Korruption verstreut und zufällig: Nur jene Byte-Sequenzen, die zufälligerweise keine gültigen UTF-8-Sequenzen sind, werden verfälscht – und bei binären Formaten wie Bildern geschieht das ständig.

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

Wenn davon ausgegangen wird, dass der Anfragekörper die Rohdaten des Files direkt enthält und nicht einen multipart/form-data-Inhalt, bleibt das File genau so erhalten, wie es hochgeladen wurde. Bei mehrteiligen Uploads sollte die Daten zunächst durch einen geeigneten multipart-Parser geleitet werden, um den Dateibereich zu extrahieren. Die grundlegende Lösung ist einfach: Konvertiert niemals binäre Daten in Zeichenketten. Sammelt stattdessen die eingehenden Buffer-Blöcke unverändert, verknüpft sie auf Byteebene miteinander und schreibt diese Bytes direkt auf die Festplatte oder in die Speicherung.

Derselbe Fehler, kleiner und heimtückischer: Mehrbyte-Zeichen, die über verschiedene Blöcke verteilt sind

Auch wenn tatsächlich mit Text gearbeitet wird, öffnet das Dekodieren der Blöcke nacheinander anstelle von einmaligem Dekodieren den Weg zu einer damit verbundenen, aber subtileren Variante dieses Fehlers – insbesondere dann relevant, wenn schrittweise strömende Daten verarbeitet werden:

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

Ein einzelnes Emoji oder ein Buchstabe mit Akzent kann unter UTF-8 mehrere Bytes umfassen, und die Grenzen der Datenblöcke aus einer Netzwerkverbindung oder einem Dateistrom haben keine Ahnung, wo diese mehrbyteigen Grenzen liegen. Wenn ein Datenblock zufällig mitten in einem Zeichen endet, führt die Dekodierung dieses Blocks für sich allein zu einem fehlerhaften Zeichen, das stillschweigend durch ein Ersatzzeichen ersetzt wird – obwohl die vollständige, korrekte Bytefolge die ganze Zeit über vorhanden war, nur in zwei getrennten .toString()-Aufrufen aufgeteilt, wobei jeder davon nur die Hälfte sah.

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

Der eingebaute StringDecoder von Node ist genau für diese Situation konzipiert: Er hält jede unvollständige Mehrbyte-Sequenz am Ende eines Datensatzes zurück, anstatt sie zu früh zu decodieren, und wartet darauf, dass die fehlenden Bytes im nächsten Datensatz auftauchen, bevor er den Zeichen die Endverarbeitung gibt. Dies ist eine andere Lösung als das Verknüpfen von Buffern mit Buffer.concat; sie kommt insbesondere dann zum Tragen, wenn man Text sicher während des Streamens decodieren muss, anstatt ein ganzes Binärdatei vor jeder Umwandlung zu puffern.

Kodierungsunterschiede: Eine Kodierung schreiben, eine andere lesen

Es gibt einen damit verbundenen Fehler, der genauso unauffällig ist: Die Auswahl unpassender Kodierungen auf der Schreibseite im Vergleich zur Leseseite einer Operation.

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 liest den String entsprechend dem Angabe-Parameter für die Kodierung, den man ihm übergeben hat. Wenn diese Kodierung nicht dieselbe ist, die ursprünglich zur Erstellung des Strings verwendet wurde, erhält man die ursprünglichen Bytes auf keine zuverlässige Weise zurück. Je nach verwendeter Kodierung und dem Aussehen der Eingabe kann Node völlig unterschiedliche Bytewerte decodieren oder stumm die Teile des Strings entfernen, die nicht den Regeln dieser Kodierung entsprechen, anstatt einen Fehler zu verursachen, der sofort bemerkt wird.

Buffer.alloc vs Buffer.allocUnsafe: Ein sicherheitsrelevanter Unterschied, nicht nur einer bezüglich der Leistung

Es gibt noch einen weiteren Unterschied, den man sich merken sollte – er ist hier besonders wichtig, weil ein Fehler bei seiner Anwendung nicht nur zu einem Bug führt, sondern auch zu einem potenziellen Leck sensibler Daten:

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

Buffer.allocUnsafe überspringt den Schritt, das von ihm bereitgestellte Speicherbereich zu initialisieren, was tatsächlich schneller ist, bedeutet aber, dass der Buffer weiterhin die Bytes enthalten kann, die zuvor in diesem Speicherbereich gespeichert waren – möglicherweise ein Überrest einer früheren Anfrage, ein Fragment des Session-Tokens eines anderen Benutzers oder alles andere, was dort zuvor gespeichert wurde. Wenn Sie einen Buffer auf diese Weise allozieren und nur einen Teil davon vor dem Senden, sei es über eine Netzwerkverbindung oder in eine Datei auf der Festplatte, schreiben, laufen Sie das Risiko, Daten preiszugeben, die nichts mit der aktuellen Operation zu tun haben. Buffer.alloc verursacht einen kleinen, vorhersehbaren Aufwand, um den Speicher im Voraus zu initialisieren – das sollte Ihre Standardmethode sein. Wenden Sie allocUnsafe nur in dem eng begrenzten Fall an, wenn Sie sicher sind, den gesamten Buffer selbst vor dem Zugriff durch andere Prozesse überschreiben zu werden.

Die eigentliche Lektion

Jeder dieser Fehler beruht auf derselben zugrundeliegenden Verwechslung: Man behandelt eine rohe Byte-Sequenz, als wäre sie von Natur aus Text – obwohl „Text“ tatsächlich erst dann existiert, wenn man eine bewusste Entscheidung darüber getroffen hat, welche Kodierung zur Interpretation dieser Bytes verwendet werden soll. Diese Entscheidung kann fälschlich getroffen, zu früh oder an der falschen Stelle im Verarbeitungspfad getroffen werden. Der sicherere Ansatz ist es, binäre Daten so lange wie möglich als solche beizubehalten, sie als rohe Bytes zu verknüpfen und umzuwandeln, und sie erst in dem konkreten Moment in einen String umzuwandeln, in dem tatsächlich Text benötigt wird – wobei dabei die korrekte, ausdrücklich gewählte Kodierung verwendet wird. Ohne diese Disziplin zeigt sich eine Korruption nicht als offensichtlicher Fehler. Sie ersetzt einfach leise die Bytes, die sie nicht interpretieren konnte, und man entdeckt das Problem erst später – meistens, weil ein Benutzer berichtet hat, dass etwas nicht funktioniert.

.

Zusätzliche Lektüre