Sichere Bild-Uploads in Node.js

Diagramm eines Uploads im Express-Beispiel: Browser, multer Memory Storage, die Prüfungen in gallery.mjs, die Datei auf der Festplatte und express.static, Fehler werden als 400 oder 413 zurückgegeben

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:

JavaScript · gallery.mjs
// 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)
  );
}
  1. 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.
  2. Die ersten Bytes müssen zu dieser Dateiendung passen: FF D8 FF für JPEG, die 8 Byte lange PNG-Signatur, GIF87a oder GIF89a und RIFF…WEBP. Ein in holiday.jpg umbenanntes Skript scheitert hier. multers req.file.mimetype wird überhaupt nicht verwendet: Es ist der Content-Type, den der Browser gesendet hat, und der Browser kann alles Mögliche senden.
  3. Der Ordner muss ein gültiger virtueller Pfad innerhalb des Stammordners sein (siehe Galerieordner in Node.js).
JavaScript · gallery.mjs
// 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

JavaScript · server.mjs
// 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 von uploads/ liegt. Wir haben von Hand eine stray.html in uploads/ 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.jpg in einem Ordner, der mit einem Punkt beginnt. Sie erhielt ein 404. Express 5 tut das standardmäßig. Express 4.22 lieferte in unserem Test uploads/.secret/a.txt mit einem 200 aus, deshalb schreibt das Beispiel die Option ausdrücklich hin.
  • X-Content-Type-Options: nosniff weist den Browser an, den Content-Type beizubehalten, den der Server gesendet hat. Die Polyglot-Datei von oben kam als Content-Type: image/jpeg mit nosniff zurü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 (mit HOST ä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:

Shell
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/jpeg

Die 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

Weiterlesen