Galerieordner in Node.js: sicher durchsuchen und anlegen

Der Dialog „Insert Gallery“ im Ordner blog des Express-Beispiels mit vier Bildern und dem Pfad /blog

Sobald die Galerie mehr als ein paar Dutzend Bilder enthält, wünschen sich die Benutzer Ordner: einen für den Blog, einen für Produkte. Der Dialog von RichTextEditor kann Ordner durchsuchen und neue anlegen, aber jeder Ordnername, den er sendet, ist eine Benutzereingabe, die in einem Dateipfad landet. Diese Seite zeigt, wie das Express-Beispiel diese Namen in Pfade unterhalb von uploads/ umwandelt und alles andere ablehnt.

Virtuelle Ordner statt Pfade auf der Festplatte

Der Dialog sendet immer nur virtuelle Ordner: "" für den Stammordner, blog, blog/2026. Sie verwenden immer /, unabhängig vom Betriebssystem des Servers. Wo sie auf der Festplatte liegen, entscheidet der Server. Im Beispiel ist blog/2026 der Ordner uploads/blog/2026, im Modus mit Ordnern pro Benutzer uploads/users/alice/blog/2026. Der Browser erfährt keinen der beiden Pfade.

Beim Öffnen eines Ordners sendet der Dialog GET /rte/gallery?folder=blog. Die Schaltfläche New folder fragt nach einem Namen und sendet action=create-folder, den übergeordneten Ordner folder und den neuen Namen name per POST an dieselbe URL. Uploads senden den geöffneten Ordner zusammen mit der Datei.

Eine Regel für jeden Teil des Pfads

Ein Ordnername darf aus 1 bis 64 Buchstaben, Ziffern, Leerzeichen, -, _ und . bestehen und nicht mit einem Punkt beginnen. Das Beispiel ergänzt zwei Windows-Regeln: kein Punkt und kein Leerzeichen am Ende, weil Windows sie entfernt und aus photos. stillschweigend photos würde, und keine Gerätenamen wie CON oder NUL.

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

resolveFolder() teilt den virtuellen Ordner an / auf und wendet diese Regel auf jeden Teil an. Diese eine Prüfung schließt bereits .., ., leere Teile (die entstehen bei /etc und a//b), Backslashes, : (also keine Laufwerksbuchstaben), % (also kein doppelt kodiertes %2e%2e) und NUL aus. Danach löst die Funktion den Pfad auf und bestätigt, dass er noch innerhalb des Stammordners liegt:

JavaScript · gallery.mjs
// Turns a virtual folder into a disk path inside root. Every segment must be a valid
// name, which already rules out "..", ".", "", "\", ":", "%" and NUL. Throws 400 if the
// folder is invalid and 404 if it does not exist.
export async function resolveFolder(root, folder) {
  if (folder === undefined || folder === null) folder = "";
  if (typeof folder !== "string" || folder.length > 255) {
    throw new GalleryError(400, "Invalid folder.");
  }
  const segments = folder === "" ? [] : folder.split("/");
  if (!segments.every(isValidName)) {
    throw new GalleryError(400, "Invalid folder.");
  }

  await mkdir(root, { recursive: true });
  const dir = path.resolve(root, ...segments);
  let info;
  try {
    info = await lstat(dir);
  } catch {
    throw new GalleryError(404, "Folder not found.");
  }
  if (!info.isDirectory() || !(await isInside(root, dir))) {
    throw new GalleryError(404, "Folder not found.");
  }
  return dir;
}

// True when target (after following any links) is root or a folder below it.
async function isInside(root, target) {
  const realRoot = await realpath(root);
  const relative = path.relative(realRoot, await realpath(target));
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}

Ungültige Eingaben erhalten ein 400, ein gültiger Name, der nicht existiert, ein 404. Der letzte Schritt vergleicht wegen Links die Ergebnisse von realpath() statt Strings. Wir haben eine Windows-Junction namens linked in uploads/ angelegt, die auf einen Ordner an anderer Stelle der Festplatte zeigt. ?folder=linked und ?folder=linked/sub erhielten beide ein 404, und die Liste des Stammordners zeigte sie nicht an.

Bei express.static sieht es anders aus: Es folgt Links. Bevor wir eine Prüfung ergänzt hatten, lieferte /uploads/linked/lake.jpg die Datei aus dem anderen Ordner mit einem 200 aus. Die Galerie-API kann keine Links anlegen, das kann also nur jemand mit Zugriff auf die Festplatte. Trotzdem führt das Beispiel jetzt vor express.static die Funktion isServableUpload() mit demselben realpath()-Test aus. Nach dieser Änderung antwortete dieselbe URL mit 404:

JavaScript · gallery.mjs
// For GET /uploads/<path>: an image name, and a real path inside UPLOADS_DIR. express.static
// alone follows a symbolic link or junction in uploads/ to the files it points at.
export async function isServableUpload(urlPath) {
  let relative;
  try {
    relative = decodeURIComponent(urlPath);
  } catch {
    return false;
  }
  if (!isImageName(path.posix.basename(relative)) || relative.includes("\\") || relative.includes("\0")) return false;
  return isInside(UPLOADS_DIR, path.join(UPLOADS_DIR, relative)).catch(() => false);
}

Einen Ordner anlegen

Die Route liest die drei Felder, die multer geparst hat, und ruft createFolder() auf. Ein POST ohne Multipart lässt req.body in Express 5 undefined (unter Express 4.22 haben wir dasselbe beobachtet), deshalb liest die Route req.body ?? {}:

JavaScript · server.mjs
// Every /rte request gets its storage root first, before multer reads the body.
app.use("/rte", (req, res, next) => {
  req.storageRoot = rootFor(req);
  next();
});

// GET /rte/gallery?folder=products  -> listing
app.get("/rte/gallery", async (req, res) => {
  res.json(await listFolder(req.storageRoot, req.query.folder ?? ""));
});

// POST /rte/gallery  action=create-folder, folder, name  -> listing of the parent folder
app.post("/rte/gallery", parseForm.none(), async (req, res) => {
  const { action, folder, name } = req.body ?? {};
  if (action !== "create-folder") throw new GalleryError(400, "Unknown action.");
  res.json(await createFolder(req.storageRoot, folder ?? "", name));
});

// POST /rte/upload  file, folder  -> { url }
app.post("/rte/upload", parseForm.single("file"), async (req, res) => {
  if (!req.file) throw new GalleryError(400, "No file was uploaded.");
  const url = await saveImage(req.storageRoot, req.body.folder ?? "", req.file.originalname, req.file.buffer);
  res.json({ url });
});
JavaScript · gallery.mjs
// Creates <parent>/<name> and returns the listing of the parent.
export async function createFolder(root, parent, name) {
  if (!isValidName(name)) {
    throw new GalleryError(400, "Folder names use 1-64 letters, digits, spaces, - _ and . and cannot start or end with a dot.");
  }
  if (isUsersFolder(root, parent)) throw new GalleryError(404, "Folder not found.");
  if (!parent && isUsersFolder(root, name)) throw new GalleryError(400, "The folder name users is reserved.");
  const dir = await resolveFolder(root, parent);
  try {
    await mkdir(path.join(dir, name));
  } catch (error) {
    if (error.code === "EEXIST") throw new GalleryError(409, "A folder with that name already exists.");
    throw error;
  }
  return listFolder(root, parent || "");
}

mkdir() ohne recursive schlägt mit EEXIST fehl, wenn der Ordner schon existiert, und daraus wird ein 409. Bei Erfolg ist die Antwort die Liste des übergeordneten Ordners, sodass der Dialog den neuen Ordner sofort ohne zweiten Request anzeigt. Im gemeinsamen Modus ist der Name users im Stammordner reserviert. Dort liegen die Ordner pro Benutzer.

Ordner, die mit einem Punkt beginnen

Die Liste blendet alles aus, was mit einem Punkt beginnt, und die Namensregel verweigert das Öffnen solcher Ordner. Die Bilder liefert express.static aus, das eine eigene Regel hat, und die hat sich zwischen den Versionen geändert. Wir haben uploads/.secret/a.txt auf der Festplatte angelegt und ohne die Option dotfiles angefordert. Express 4.22 (serve-static 1.16) lieferte die Datei mit einem 200 aus. Express 5.3 (serve-static 2.2) antwortete mit 404. Beide verweigerten /.env selbst. Das Beispiel setzt dotfiles: "ignore" ausdrücklich, damit das Verhalten nicht von der Version abhängt, die Sie verwenden.

Testen

Starten Sie das Beispielprojekt zu dieser Anleitung mit npm start und probieren Sie einige Namen aus:

Shell
curl -F action=create-folder -F folder=blog -F name=2026 http://localhost:5101/rte/gallery
{"currentFolder":"blog","currentFolderDisplay":"/blog","parentFolder":"","folders":[{"folder":"blog/2026","name":"2026"}],...}

curl -F action=create-folder -F folder=blog -F name=2026 http://localhost:5101/rte/gallery
{"error":"A folder with that name already exists."}

curl -F action=create-folder -F folder= -F name=.hidden http://localhost:5101/rte/gallery
{"error":"Folder names use 1-64 letters, digits, spaces, - _ and . and cannot start or end with a dot."}

curl "http://localhost:5101/rte/gallery?folder=a/../../x"
{"error":"Invalid folder."}

Die Statuscodes sind 200, 409, 400 und 400. Klicken Sie im Browser auf Insert Gallery und dann auf New folder: Der neue Ordner erscheint zwischen den anderen. Öffnen Sie ihn, und Upload legt die Datei in diesem Ordner ab.

Dieselbe Anleitung für andere Plattformen

PHP · ASP.NET Core · MVC 5 · Web Forms

Weiterlesen