Sichere Bild-Uploads in Node.js
Ein Endpoint für Bild-Uploads ist für jeden eine Möglichkeit, eine Datei auf Ihren Server zu legen und sie dann von Ihrer eigenen Domain ausliefern zu lassen. Diese Seite geht durch, was das Express-Beispiel prüft, bevor es eine Datei schreibt, wie es die Dateien danach ausliefert und was die Prüfungen nicht beweisen können, jeweils mit den Requests, mit denen wir jeden Teil getestet haben.
Was der Server vor dem Schreiben prüft
Das Diagramm oben zeigt die Reihenfolge. multer liest das Formular in den Speicher (seine Limits stehen in Größenlimits für Bild-Uploads in Node.js). Dann führt saveImage() drei Prüfungen aus, und erst danach wird etwas auf die Festplatte geschrieben:
// Allowed extensions, each with a check of the file's first bytes (its signature).
const SIGNATURES = {
".jpg": (b) => hex(b, 0, 3) === "ffd8ff",
".jpeg": (b) => hex(b, 0, 3) === "ffd8ff",
".png": (b) => hex(b, 0, 8) === "89504e470d0a1a0a",
".gif": (b) => ascii(b, 0, 6) === "GIF87a" || ascii(b, 0, 6) === "GIF89a",
".webp": (b) => ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 12) === "WEBP",
};
const hex = (bytes, start, end) => bytes.subarray(start, end).toString("hex");
const ascii = (bytes, start, end) => bytes.subarray(start, end).toString("latin1");
// True for "chair.jpg", false for ".chair.jpg", "chair.svg" or "chair".
export function isImageName(name) {
return !name.startsWith(".") && Object.hasOwn(SIGNATURES, extensionOf(name));
}
export class GalleryError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
// A folder or file name: 1-64 letters, digits, space, - _ . and not starting with a dot.
// Also refused: a trailing dot or space (Windows drops them, so "a." would become "a")
// and Windows device names such as CON or NUL.
export function isValidName(name) {
return (
typeof name === "string" &&
/^[A-Za-z0-9 _.-]{1,64}$/.test(name) &&
!name.startsWith(".") &&
!/[. ]$/.test(name) &&
!/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(name)
);
}- Die Dateiendung muss .jpg, .jpeg, .png, .gif oder .webp sein. Das ist eine Allowlist, also werden .svg, .html und .php abgelehnt, ohne dass der Server sie kennen muss. SVG fehlt absichtlich: Es kann Skripte enthalten.
- Die ersten Bytes müssen zu dieser Dateiendung passen:
FF D8 FFfür JPEG, die 8 Byte lange PNG-Signatur,GIF87aoderGIF89aundRIFF…WEBP. Ein inholiday.jpgumbenanntes Skript scheitert hier. multersreq.file.mimetypewird überhaupt nicht verwendet: Es ist derContent-Type, den der Browser gesendet hat, und der Browser kann alles Mögliche senden. - Der Ordner muss ein gültiger virtueller Pfad innerhalb des Stammordners sein (siehe Galerieordner in Node.js).
// Checks the name and the bytes, stores the file under a new random name, returns its URL.
export async function saveImage(root, folder, originalName, bytes) {
const ext = extensionOf(originalName);
const matches = Object.hasOwn(SIGNATURES, ext) && SIGNATURES[ext];
if (!matches) {
throw new GalleryError(400, "Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.");
}
if (bytes.length > MAX_BYTES) {
throw new GalleryError(413, "The image is larger than 4,000,000 bytes.");
}
if (!matches(bytes)) {
throw new GalleryError(400, "The file content is not a valid " + ext.slice(1).toUpperCase() + " image.");
}
if (isUsersFolder(root, folder)) throw new GalleryError(404, "Folder not found.");
const dir = await resolveFolder(root, folder);
const storedName = baseNameOf(originalName) + "-" + randomBytes(4).toString("hex") + ext;
const file = path.join(dir, storedName);
await writeFile(file, bytes, { flag: "wx" }); // "wx" fails instead of overwriting
return publicUrl(file);
}Der ursprüngliche Dateiname wird nie zum gespeicherten Namen. Er wird auf a-z, 0-9 und - reduziert, auf 40 Zeichen gekürzt und um acht zufällige Hex-Zeichen ergänzt. Die ursprüngliche Dateiendung stammt aus der Allowlist und wird kleingeschrieben. Durch das Schreiben mit dem Flag "wx" wird eine vorhandene Datei nie ersetzt.
Was die Prüfungen nicht beweisen können
Eine Signaturprüfung sagt Ihnen, wie eine Datei beginnt, nicht, was danach kommt. Wir haben eine Datei namens polyglot.jpg hochgeladen, die mit den JPEG-Bytes FF D8 FF E0 beginnt und mit <html><script>alert(1)</script> weitergeht. Sie wurde angenommen. Das ist zu erwarten: Um zu beweisen, dass eine Datei als Ganzes ein gültiges Bild ist, muss man sie dekodieren, mit einer Bildbibliothek wie sharp, und sie neu kodieren, wenn darin Verstecktes entfernt werden soll. Das Beispiel tut das nicht. Harmlos wird eine solche Datei dadurch, wie sie ausgeliefert wird.
Wie die Dateien ausgeliefert werden
// Uploaded images. Only image files inside uploads/ are served (isServableUpload), so a
// stray .html or .svg, or a link to another folder, is never served from your site.
// dotfiles "ignore" answers 404 for .files and anything inside a .folder (Express 5's
// default, written out because Express 4 served files inside .folders).
// nosniff stops the browser from guessing a content type other than the image's own.
app.use(
"/uploads",
async (req, res, next) => ((await isServableUpload(req.path)) ? next() : res.status(404).end()),
express.static(UPLOADS_DIR, {
dotfiles: "ignore",
index: false,
setHeaders: (res) => res.setHeader("X-Content-Type-Options", "nosniff"),
}),
);
// The editor, the shared browser file and the demo page.
if (EDITOR_DIR) app.use("/richtexteditor", express.static(EDITOR_DIR));
app.get("/rte-files.js", (req, res) => res.sendFile(RTE_FILES_JS));
app.use(express.static(path.join(import.meta.dirname, "public")));isServableUpload()lässt nur Bild-Dateinamen durch, deren realer Pfad innerhalb vonuploads/liegt. Wir haben von Hand einestray.htmlinuploads/gelegt. Sie erhielt ein 404, ebenso ein Link auf einen Ordner außerhalb.dotfiles: "ignore"deckt ab, was diese Prüfung nicht abdeckt: eine Datei wie.secret/a.jpgin einem Ordner, der mit einem Punkt beginnt. Sie erhielt ein 404. Express 5 tut das standardmäßig. Express 4.22 lieferte in unserem Testuploads/.secret/a.txtmit einem 200 aus, deshalb schreibt das Beispiel die Option ausdrücklich hin.X-Content-Type-Options: nosniffweist den Browser an, denContent-Typebeizubehalten, den der Server gesendet hat. Die Polyglot-Datei von oben kam alsContent-Type: image/jpegmitnosniffzurück, also behandelt ein Browser sie als Bild und führt ihr Skript nie aus.
Was das Beispiel Ihnen überlässt
- Wer hochladen darf. Es gibt kein Login. Der Server lauscht standardmäßig auf
127.0.0.1(mitHOSTändern Sie das), sodass ihn beim Ausprobieren nichts außerhalb Ihres Rechners erreicht. Bildordner pro Benutzer in Node.js zeigt, wo Ihr Login angebunden wird. - CSRF. Express hat keinen eingebauten Schutz für die beiden POST-Routen. Wenn Ihr Login Cookies verwendet, kann eine andere Website den Browser eines angemeldeten Benutzers dazu bringen, an diese Routen zu senden. Prüfen Sie den Header
Origin, wie es das Next.js-Beispiel tut, oder verwenden Sie ein CSRF-Token. - Kontingente. Nichts hindert einen Benutzer daran, die Festplatte in 4-MB-Schritten zu füllen.
Testen
Starten Sie das Beispielprojekt zu dieser Anleitung mit npm start und senden Sie ihm die Dateien von dieser Seite:
printf '<?php echo 1; ?>' > fake.jpg
curl -F "file=@fake.jpg" http://localhost:5101/rte/upload
{"error":"The file content is not a valid JPG image."}
curl -F "file=@photo.jpg;filename=x.svg" http://localhost:5101/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}
curl -F "file=@photo.jpg" http://localhost:5101/rte/upload
{"url":"/uploads/photo-05a3e02b.jpg"}
curl -I http://localhost:5101/uploads/photo-05a3e02b.jpg
HTTP/1.1 200 OK
X-Content-Type-Options: nosniff
Content-Type: image/jpegDie ersten beiden Antworten sind HTTP 400. Im Galerie-Dialog zeigt dieselbe gefälschte Datei „Upload failed for fake.jpg.“ an. Der Dialog verwendet seinen eigenen Text, nicht die Meldung des Servers.
Dieselbe Anleitung für andere Plattformen
Next.js · PHP · ASP.NET Core · Razor Pages · MVC 5 · Web Forms