Envoi d’images sécurisé en Node.js

Schéma d’un envoi dans l’exemple Express : navigateur, stockage en mémoire de multer, vérifications de gallery.mjs, fichier sur le disque et express.static, avec les échecs renvoyés en 400 ou 413

Un point de terminaison d’envoi d’images permet à n’importe qui de déposer un fichier sur votre serveur, puis de le faire servir par votre propre domaine. Cette page passe en revue ce que vérifie l’exemple Express avant d’écrire un fichier, comment il sert ensuite les fichiers, et ce que les vérifications ne peuvent pas prouver, avec les requêtes que nous avons utilisées pour tester chaque partie.

Ce que vérifie le serveur avant d’écrire

Le schéma en haut de la page montre l’ordre. multer lit le formulaire en mémoire (ses limites sont décrites dans les limites de taille d’envoi d’images en Node.js). Ensuite, saveImage() effectue trois vérifications, et ce n’est qu’après que quoi que ce soit touche le disque :

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. L’extension doit être .jpg, .jpeg, .png, .gif ou .webp. C’est une liste d’autorisation : .svg, .html et .php sont donc refusés sans que le serveur ait besoin de les connaître. Le SVG est exclu volontairement : il peut contenir du script.
  2. Les premiers octets doivent correspondre à cette extension : FF D8 FF pour le JPEG, la signature PNG de 8 octets, GIF87a ou GIF89a, et RIFF…WEBP. Un script renommé en holiday.jpg échoue ici. Le req.file.mimetype de multer n’est pas du tout utilisé : c’est le Content-Type envoyé par le navigateur, et le navigateur peut envoyer n’importe quoi.
  3. Le dossier doit être un chemin virtuel valide à l’intérieur de la racine de stockage (voir les dossiers de galerie en 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);
}

Le nom de fichier d’origine ne devient jamais le nom stocké. Il est réduit à a-z, 0-9 et -, tronqué à 40 caractères, puis suivi de huit caractères hexadécimaux aléatoires. L’extension d’origine provient de la liste d’autorisation, en minuscules. Écrire avec l’indicateur "wx" garantit qu’un fichier existant n’est jamais remplacé.

Ce que les vérifications ne peuvent pas prouver

Une vérification de signature vous dit comment un fichier commence, pas ce qui suit. Nous avons envoyé un fichier nommé polyglot.jpg qui commence par les octets JPEG FF D8 FF E0 et se poursuit par <html><script>alert(1)</script>. Il a été accepté. C’est normal : prouver qu’un fichier est une image valide dans son ensemble suppose de le décoder, avec une bibliothèque d’images comme sharp, puis de le réencoder si vous voulez supprimer ce qui y serait caché. L’exemple ne le fait pas. Ce qui rend un tel fichier inoffensif, c’est la façon dont il est servi.

Comment les fichiers sont servis

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() ne laisse passer que les noms de fichiers d’images dont le chemin réel se trouve à l’intérieur de uploads/. Nous avons placé un stray.html dans uploads/ à la main. Il a reçu un 404, tout comme un lien vers un dossier extérieur.
  • dotfiles: "ignore" couvre ce que cette vérification ne couvre pas : un fichier comme .secret/a.jpg dans un dossier commençant par un point. Il a reçu un 404. Express 5 le fait par défaut. Express 4.22 a servi uploads/.secret/a.txt avec un 200 lors de notre test : l’exemple écrit donc l’option explicitement.
  • X-Content-Type-Options: nosniff indique au navigateur de conserver le Content-Type envoyé par le serveur. Le polyglotte ci-dessus est revenu avec Content-Type: image/jpeg et nosniff : un navigateur le traite donc comme une image et n’exécute jamais son script.

Ce qui reste à votre charge

  • Qui a le droit d’envoyer. Il n’y a pas de connexion. Le serveur écoute par défaut sur 127.0.0.1 (HOST permet de le changer) : rien d’extérieur à votre machine ne peut donc l’atteindre pendant vos essais. Dossiers d’images par utilisateur en Node.js montre où brancher votre connexion.
  • CSRF. Express n’a pas de protection intégrée pour les deux routes POST. Si votre connexion utilise des cookies, un autre site peut faire envoyer des requêtes vers ces routes par le navigateur d’un utilisateur connecté. Vérifiez l’en-tête Origin, comme le fait l'exemple Next.js, ou utilisez un jeton CSRF.
  • Quotas. Rien n’empêche un utilisateur de remplir le disque, 4 Mo à la fois.

Tester

Démarrez le projet d’exemple de ce guide avec npm start et envoyez-lui les fichiers de cette page :

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

Les deux premières réponses sont des HTTP 400. Dans la boîte de dialogue de la galerie, le même faux fichier affiche « Upload failed for fake.jpg. ». La boîte de dialogue utilise son propre texte, et non le message du serveur.

Le même guide pour d’autres plateformes

Next.js · PHP · ASP.NET Core · Razor Pages · MVC 5 · Web Forms

À lire ensuite