Umgang mit Bild-Uploads auf dem Server
Node-Bild-Uploads absichern: Dateisignaturen im Speicher prüfen, Upload-Größen begrenzen, mit sharp neu kodieren und Dateien sicher ausliefern.
Die serverseitige Validierung von Datei-Uploads beginnt, sobald der Multipart-Request eintrifft — und ab diesem Moment ist einzig den Bytes zu vertrauen. Content-Type und Dateiname kommen beide vom Browser, betrachte sie also als Behauptungen, nicht als Fakten. Falls du von den browserseitigen Artikeln über das Erstellen von Bild-Thumbnails vor dem Upload und das Konvertieren von Bildern in Base64 mit Canvas hierher gefunden hast: Das hier ist die Empfängerseite dieser Pipeline.
Die beiden Hälften der Pipeline erfüllen unterschiedliche Aufgaben. Browserseitiges Skalieren und Komprimieren ist ein Service für den Nutzer; die serverseitige Validierung ist das, was einen 2 GB großen Body oder ein Skript im .jpg-Kostüm aufhält. Dieser Artikel geht fünf Kontrollmechanismen durch, die ein Node-Bild-Endpoint braucht: Signaturprüfungen, mehrschichtige Größenbeschränkungen, generierte Dateinamen, Neukodierung und sicheres Ausliefern.
Die wichtigsten Erkenntnisse
- Der Content-Type-Header und der Dateiname in einem Multipart-Upload werden beide vom Client gesetzt. Ein Server, der eines von beiden prüft, validiert also die Behauptung des Angreifers über die Datei — nicht die Datei selbst.
- Eine Signaturprüfung sollte die führenden Bytes der Datei im Speicher lesen, bevor irgendetwas die Festplatte berührt, und sie mit den Formaten vergleichen, die genau dieser Endpoint akzeptiert.
- Größenbeschränkungen gehören zuerst in den Proxy, zweitens in Multers
limits-Option und erst zuletzt in den Anwendungscode. MultersfileSizesteht standardmäßig auf Infinity — ein nicht gesetztes Limit ist also kein Limit. - Die Neukodierung mit sharp macht das Fälschen von Signaturen irrelevant: Die Ausgabe ist eine neue Datei, und EXIF-Metadaten — inklusive GPS-Koordinaten aus Handyfotos — überleben diesen Schritt nicht.
- Liefere gespeicherte Bilder mit einem Content-Type aus deinem eigenen Validierungsdatensatz aus, setze
X-Content-Type-Options: nosniffund wähle einen Speicherpfad außerhalb des Web-Roots.
Warum scheitern Prüfungen von MIME-Type und Dateiendung?
Die Prüfung von file.mimetype oder der Dateiendung validiert die Datei nicht, denn beide Werte liefert der Client. Zwei frühere Artikel in diesem Blog haben genau diesen Ratschlag gegeben: Multer NPM: File Upload in Node.js zeigt einen fileFilter, der jede Datei akzeptiert, deren mimetype in einer Positivliste auftaucht, und Safe User Input Handling in Node.js empfiehlt Lesern, MIME-Typen auf Parser-Ebene zu validieren. Beide Prüfungen sind als günstige Frühabweisung durchaus sinnvoll, aber keine von beiden ist ein Sicherheitsmechanismus.
Die Fälschung braucht eine einzige Zeile:
curl -F "file=@payload.sh;type=image/jpeg" https://example.com/upload
Multer kopiert diesen type-Wert direkt in req.file.mimetype. Dein Filter sieht image/jpeg; der Body ist ein Shell-Skript. Das OWASP File Upload Cheat Sheet stellt ausdrücklich klar, dass die Validierung auf dem Dateiinhalt basieren muss, nicht auf clientseitig gelieferten Metadaten.
Upload-Validierung beginnt mit der Dateisignatur
Lies die führenden Bytes des Buffers im Speicher, bevor irgendetwas auf die Festplatte geschrieben wird, und vergleiche sie mit den Formaten, die genau dieser Endpoint akzeptiert. Beide Hälften dieses Satzes sind wichtig. Tutorials validieren häufig erst, nachdem die Datei bereits in einem Upload-Verzeichnis gelandet ist — was bedeutet, dass eine überdimensionierte oder bösartige Payload ihren Schaden schon angerichtet hat, bevor die Prüfung überhaupt läuft. Und sie lassen eine Datei häufig durch, wenn sie irgendeiner bekannten Signatur entspricht — womit ein PDF anstandslos durch einen Avatar-Endpoint segelt. Ein Avatar-Endpoint, der PDFs erkennt, hat einen Validierungsfehler, kein Feature.
Die Signaturen selbst: JPEG beginnt mit FF D8 FF, PNG beginnt mit der vollständigen 8-Byte-Sequenz aus der PNG-Spezifikation, und WebP verlangt RIFF an Offset 0 sowie WEBP an den Bytes 8 bis 11, gemäß der WebP-Container-Spezifikation. Die Bytes 4 bis 7 enthalten die RIFF-Chunk-Größe — deshalb muss eine WebP-Prüfung sie überspringen, und deshalb behandelt ein naives 4-Byte-Lesen weder PNG noch WebP korrekt.
const SIGNATURES = {
"image/jpeg": (b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
"image/png": (b) =>
b.length >= 8 &&
b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
"image/webp": (b) =>
b.length >= 12 &&
b.subarray(0, 4).toString("ascii") === "RIFF" &&
b.subarray(8, 12).toString("ascii") === "WEBP",
};
// Allowlist is per endpoint: this one accepts photos, nothing else
function detectImageType(buffer, allowed = ["image/jpeg", "image/png", "image/webp"]) {
return allowed.find((type) => SIGNATURES[type](buffer)) ?? null;
}
Eine Einschränkung: Signaturen lassen sich fälschen, indem man einer beliebigen Payload die passenden Bytes voranstellt — in Sekunden. Das file-type-Paket sagt das in seiner eigenen README selbst, wo das Abgleichen von Magic Numbers als Hinweis auf das Format beschrieben wird und nicht als Beweis dafür. Betrachte die Signaturprüfung als schnellen, günstigen Filter. Die eigentliche Sicherheitsgrenze kommt zwei Abschnitte weiter unten.
Wo gehören Größenbeschränkungen für Uploads hin?
Größenbeschränkungen gehören zuerst in den Proxy, dann in das Framework und zuletzt in den Anwendungscode — denn eine Prüfung, die erst läuft, nachdem der Body gepuffert wurde, weist den Upload erst ab, wenn die vollständige Payload dich bereits Speicher und Bandbreite gekostet hat. In nginx steht client_max_body_size standardmäßig auf 1 MB und beantwortet zu große Requests mit einem 413, bevor dein Prozess sie überhaupt sieht:
client_max_body_size 5m;
Die Framework-Ebene ist Multer (2.x), wo limits.fileSize standardmäßig auf Infinity steht — ein nicht gesetztes Limit ist also kein Limit:
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
});
Wird die Obergrenze erreicht, wirft Multer einen LIMIT_FILE_SIZE-Fehler. Bei Fastify verfolgt @fastify/multipart den umgekehrten Ansatz und setzt fileSize standardmäßig auf 1 MiB — dort ist das sichere Verhalten also der Standard und nicht die Opt-in-Variante. Session Replays von Upload-Flows machen die Kosten sichtbar, die entstehen, wenn man die Proxy-Ebene auslässt — nämlich als UX-Mangel: Der Fortschrittsbalken erreicht 100 Prozent, dann erscheint die Abweisung, weil die gesamte Payload eintreffen musste, bevor die Prüfung auf Anwendungsebene laufen konnte.
Verwirf den Dateinamen des Clients
Lass den Dateinamen des Clients niemals dein Dateisystem berühren. Generiere deinen eigenen aus crypto.randomUUID() plus der Endung, die deine eigene Validierung ermittelt hat:
const storedName = `${crypto.randomUUID()}.jpg`;
Die Begründung — von Traversal-Sequenzen bis zu der Frage, warum path.normalize keine Verteidigung ist — behandelt Preventing Path Traversal Attacks in Node.js; der Ansatz mit generierten Namen macht die gesamte Angriffsklasse unerreichbar.
Kodiere das Bild neu; speichere nicht die Originalbytes
Die Neukodierung ist der Mechanismus, der das Fälschen von Signaturen irrelevant macht. sharp (0.35.x) dekodiert die Pixel und schreibt eine brandneue Datei — was auch immer im Original vorangestellt, angehängt oder darin versteckt war, überlebt das nicht. Sie schließt außerdem ein Datenschutzleck: Handyfotos tragen routinemäßig GPS-Koordinaten in ihren EXIF-Daten, und wer die Originalbytes speichert, veröffentlicht damit den Standort seiner Nutzer erneut. Die sharp-Output-Dokumentation benennt den Standard klar: Nichts aus den Metadaten der Eingabe gelangt in die Ausgabe, sofern du es nicht mit keepExif() oder withMetadata() explizit zurückholst. Das Orientierungs-Flag geht mit allem anderen verloren — also erst mit .autoOrient() automatisch ausrichten, sonst kommen Handyfotos seitwärts heraus:
let clean;
try {
clean = await sharp(req.file.buffer)
.autoOrient() // apply EXIF orientation before metadata is stripped
.jpeg({ quality: 85 })
.toBuffer();
} catch {
return res.status(422).send("Not a decodable image"); // decode failure is a rejection
}
Ein Buffer, der die Signaturprüfung passiert hat, sich aber nicht dekodieren lässt, hat über sein Format gelogen. Genau so soll die Prüfung wirken.
Liefere aus, was du validiert hast — nicht, was du empfangen hast
Setze beim Ausliefern gespeicherter Bilder den Content-Type aus deinem Validierungsdatensatz, niemals aus irgendetwas, das der Client geschickt hat. Ergänze X-Content-Type-Options: nosniff, damit der Browser ihn nicht selbst infrage stellt, und speichere Dateien außerhalb des Web-Roots, sodass nichts Hochgeladenes jemals direkt ausführbar oder darstellbar ist:
app.get("/images/:id", async (req, res) => {
const record = await getImageRecord(req.params.id); // contentType saved at validation time
if (!record) return res.sendStatus(404);
res.setHeader("Content-Type", record.contentType);
res.setHeader("X-Content-Type-Options", "nosniff");
res.sendFile(record.storedName, { root: UPLOAD_DIR }); // UPLOAD_DIR is outside the web root
});
Ein paar angrenzende Themen, jeweils in einer Zeile:
- Virenscans (ClamAV oder ein Cloud-Äquivalent) sind relevant, wenn du beliebige Dokumente annimmst — nicht bei neukodierten Bildern.
- Falls du jemals Archive annimmst, prüfe die dekomprimierte Größe vor dem Entpacken; Zip-Bomben sind kleine Dateien, die sich enorm ausdehnen.
- SVG ist ein XML-Dokument, das Skripte enthalten kann — schließe es von Bild-Endpoints komplett aus.
- Architekturen mit Presigned URLs laden direkt in den Object Storage hoch, was diese gesamte Pipeline in einen Verarbeitungsschritt nach dem Upload verlagert, sie aber nicht überflüssig macht.
Fazit
Die fünf Mechanismen bilden eine Pipeline: Abweisung wegen Größe im Proxy, Signaturprüfung im Speicher gegen die Allowlist dieses Endpoints, Neukodierung mit sharp, Speicherung unter einem selbst generierten Namen und Ausliefern mit Headern, die du kontrollierst.
| Mechanismus | Wo er läuft | Was er verhindert |
|---|---|---|
| Größenbeschränkung | Zuerst Proxy, dann Multer limits, zuletzt App-Code | Übergroße Bodies, die Speicher und Bandbreite verbrauchen |
| Signaturprüfung | Im Speicher, bevor etwas die Festplatte berührt | Bytes, die nicht zur Allowlist dieses Endpoints passen |
| Neukodierung mit sharp | Nach der Validierung, vor der Speicherung | Gefälschte Signaturen, versteckte Payloads, EXIF-GPS-Daten |
| Generierter Dateiname | Zum Zeitpunkt der Speicherung | Path Traversal über den Dateinamen des Clients |
| Validierte Auslieferungs-Header | Bei jedem Lesezugriff | MIME-Sniffing und Ausführung aus dem Web-Root |
Die Signaturprüfung filtert günstig; die Neukodierung ist die Grenze, die auch dann hält, wenn die Signatur gefälscht war. Fang damit an, deine eigene Multer-Konfiguration zu prüfen: Wenn limits.fileSize nicht gesetzt ist, akzeptiert dieser Endpoint derzeit Dateien unbegrenzter Größe.
FAQs
Ersetzt das npm-Paket file-type eine selbst geschriebene Signaturprüfung?
Es ersetzt den Byte-Vergleich, nicht das Sicherheitsmodell. file-type liest dieselben Magic Numbers, und die README ist unmissverständlich, was das bringt: Ein Treffer ist ein Hinweis und klärt weder, ob die Datei wirklich diesen Typ hat, noch ob sie wohlgeformt ist. Du brauchst darüber hinaus weiterhin eine endpointspezifische Allowlist, denn das Paket erkennt Hunderte von Formaten — und die Neukodierung bleibt die eigentliche Grenze. Beachte, dass das Paket ESM-only ist; CommonJS-Projekte brauchen also einen dynamischen Import oder den load-esm-Workaround.
Verhindert Multers fileSize-Limit, dass der Client den Rest der Datei sendet?
Nicht zuverlässig. Wird limits.fileSize erreicht, stoppt Multer das Puffern und wirft einen LIMIT_FILE_SIZE-Fehler, was deinen Prozess vor unbegrenztem Speicherverbrauch schützt. Nichts in der Dokumentation garantiert jedoch, dass die Netzwerkübertragung abgebrochen wird, und der Fehler kann erst auftreten, nachdem alle Bytes eingetroffen sind. Eine Obergrenze auf Proxy-Ebene wie nginx client_max_body_size ist das, was die Bandbreite tatsächlich schützt — deshalb gehört das Limit zuerst in den Proxy.
Wie validiert man Uploads, wenn Clients per Presigned URL direkt in den Object Storage hochladen?
Die Validierung verschiebt sich in einen Schritt nach dem Upload, sie verschwindet nicht. Der Client lädt in einen Quarantäne-Bucket oder ein Quarantäne-Prefix hoch, aus dem nichts ausgeliefert wird. Danach lädt ein Background-Worker oder eine vom Storage getriggerte Funktion das Objekt herunter, führt dieselbe Signaturprüfung und die sharp-Neukodierung durch und schreibt die bereinigte Ausgabe unter einem generierten Namen an den öffentlichen Ort. Objekte, welche die Validierung nicht bestehen, werden gelöscht, und der Quarantäne-Ort wird Browsern nie zugänglich gemacht.
Verändert die Neukodierung mit sharp die Farben des Bildes?
Bei Bildern mit großem Farbraum kann sie das. Ohne weitere Eingriffe liefert sharp eine sRGB-Ausgabe ohne angehängtes Profil, da das Entfernen der Metadaten auch das eingebettete ICC-Profil mitnimmt — Fotos, die in Display P3 oder Adobe RGB erstellt wurden, können sich also leicht verschieben. Um die Farbe zu erhalten, ohne EXIF-Daten wieder einzuführen, rufe vor dem Kodieren keepIccProfile() auf. keepMetadata() behält das Profil ebenfalls, bringt aber alles zurück — einschließlich der GPS-Koordinaten, vor denen das Entfernen deine Nutzer geschützt hat.