Dossiers de galerie en Node.js : parcourir et créer en toute sécurité

La boîte de dialogue Insert Gallery dans le dossier blog de l’exemple Express, qui affiche quatre images avec le chemin /blog

Dès que la galerie compte plus de quelques dizaines d’images, les utilisateurs veulent des dossiers : un pour le blog, un pour les produits. La boîte de dialogue de RichTextEditor sait parcourir les dossiers et en créer de nouveaux, mais chaque nom de dossier qu’elle envoie est une saisie utilisateur qui finit dans un chemin de fichier. Cette page montre comment l’exemple Express transforme ces noms en chemins sous uploads/ et refuse tout le reste.

Des dossiers virtuels, pas des chemins sur le disque

La boîte de dialogue n’envoie jamais que des dossiers virtuels : "" pour la racine, blog, blog/2026. Ils utilisent toujours /, quel que soit le système d’exploitation du serveur. C’est le serveur qui décide où ils se trouvent sur le disque. Dans l’exemple, blog/2026 correspond à uploads/blog/2026, et en mode par utilisateur à uploads/users/alice/blog/2026. Le navigateur ne connaît jamais ni l’un ni l’autre de ces chemins.

Ouvrir un dossier envoie GET /rte/gallery?folder=blog. Le bouton New folder demande un nom et envoie action=create-folder, le dossier parent folder et le nouveau name à la même URL. Les envois de fichiers transmettent le dossier ouvert avec le fichier.

Une seule règle pour chaque partie du chemin

Un nom de dossier peut comporter de 1 à 64 lettres, chiffres, espaces, -, _ et ., et ne peut pas commencer par un point. L’exemple ajoute deux règles propres à Windows : pas de point ni d’espace à la fin, car Windows les supprime et photos. deviendrait discrètement photos, et pas de noms de périphériques comme CON ou 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() découpe le dossier virtuel sur / et applique cette règle à chaque partie. Cette seule vérification exclut déjà .., ., les parties vides (ce que produisent /etc et a//b), les barres obliques inverses, : (donc pas de lettres de lecteur), % (donc pas de %2e%2e doublement encodé) et NUL. La fonction résout ensuite le chemin et confirme qu’il se trouve toujours à l’intérieur de la racine :

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

Une entrée invalide reçoit un 400, un nom valide qui n’existe pas reçoit un 404. La dernière étape compare les résultats de realpath() plutôt que des chaînes, à cause des liens. Nous avons placé dans uploads/ une jonction Windows nommée linked, pointant vers un dossier situé ailleurs sur le disque. ?folder=linked et ?folder=linked/sub ont tous deux reçu un 404, et la liste de la racine ne l’affichait pas.

Avec express.static, c’est une autre histoire : il suit les liens. Avant que nous ajoutions une vérification, /uploads/linked/lake.jpg servait le fichier de l’autre dossier avec un 200. L’API de la galerie ne peut pas créer de liens : seule une personne ayant accès au disque peut donc faire cela. L’exemple exécute néanmoins désormais isServableUpload() avant express.static, avec le même test realpath(). Après cette modification, la même URL a répondu 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);
}

Créer un dossier

La route lit les trois champs analysés par multer et appelle createFolder(). Un POST qui n’est pas multipart laisse req.body à undefined dans Express 5 (nous avons constaté la même chose avec Express 4.22) : la route lit donc 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() sans recursive échoue avec EEXIST quand le dossier existe déjà, ce qui devient un 409. En cas de succès, la réponse est la liste du dossier parent : la boîte de dialogue affiche donc aussitôt le nouveau dossier, sans seconde requête. En mode partagé, le nom users est réservé à la racine. C’est là que se trouvent les dossiers par utilisateur.

Les dossiers qui commencent par un point

La liste masque tout ce qui commence par un point, et la règle de nommage refuse d’en ouvrir un. Les images sont servies par express.static, qui a sa propre règle, et celle-ci a changé selon les versions. Nous avons placé uploads/.secret/a.txt sur le disque et l’avons demandé sans option dotfiles. Express 4.22 (serve-static 1.16) l’a servi avec un 200. Express 5.3 (serve-static 2.2) a répondu 404. Les deux ont refusé /.env lui-même. L’exemple définit explicitement dotfiles: "ignore", pour que le comportement ne dépende pas de la version que vous utilisez.

Tester

Démarrez le projet d’exemple de ce guide avec npm start et essayez quelques noms :

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."}

Les codes d’état sont 200, 409, 400 et 400. Dans le navigateur, cliquez sur Insert Gallery, puis sur New folder : le nouveau dossier apparaît parmi les autres. Ouvrez-le, et Upload place le fichier dans ce dossier.

Le même guide pour d’autres plateformes

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

À lire ensuite