Envoi d’images sécurisé avec Next.js

Schéma d’un envoi dans l’exemple Next.js : navigateur, gestionnaire de route, readForm(), vérifications de lib/gallery.ts et route uploads, avec les échecs renvoyés en 400 ou 413

Next.js protège les Server Actions d’une manière dont les gestionnaires de route ne bénéficient pas, et les envois de RichTextEditor passent par des gestionnaires de route. Cette page passe en revue ce que l’exemple Next.js ajoute lui-même : une vérification d’Origin contre les envois intersites, les vérifications du fichier avant toute écriture, et une route qui sert les fichiers envoyés sans transformer votre domaine en hébergeur de fichiers.

Les gestionnaires de route n’ont pas de vérification d’Origin

Le guide de Next.js sur la sécurité des données indique que les Server Actions comparent l’en-tête Origin au Host (ou à X-Forwarded-Host) et interrompent la requête s’ils diffèrent. Les gestionnaires de route ne font rien de tel. Avant que nous ajoutions une vérification, nous avons envoyé POST /rte/gallery avec Origin: https://evil.example, et l’exemple a créé le dossier : 200. Si votre connexion utilise des cookies, n’importe quel autre site pourrait faire faire cela au navigateur d’un utilisateur connecté. L’exemple refuse désormais cette requête :

TypeScript · lib/gallery.ts
// Next.js checks the Origin of Server Actions, not of route handlers. A browser sends Origin
// with these POSTs, so refuse one from another site. Requests without Origin (curl) pass.
export function checkOrigin(request: Request): void {
  const origin = request.headers.get("origin");
  if (!origin) return;
  const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
  let originHost: string | null = null;
  try {
    originHost = new URL(origin).host;
  } catch {}
  if (originHost !== host) throw new GalleryError(403, "Cross-site request refused.");
}

Les deux gestionnaires POST appellent d’abord checkOrigin(request), avant la lecture du corps. Nous avons vérifié que Chromium envoie Origin: http://localhost:5102 avec les envois propres à l’éditeur : ils passent donc. Une requête sans aucun Origin, comme curl ou du code de serveur à serveur, passe aussi. Ce n’est pas une requête intersite issue d’un navigateur, et votre vérification de connexion s’y applique toujours. Origin: null, qu’envoient les pages en bac à sable, est refusé. Derrière un proxy inverse qui modifie le nom d’hôte, assurez-vous qu’il définit X-Forwarded-Host, sinon la vérification refuse aussi vos propres pages.

Ce qui est vérifié avant l’écriture

Le schéma en haut de la page montre l’ordre pour un envoi. Après la vérification d’Origin, readForm() limite la taille du corps (voir les limites de taille d’envoi d’images avec Next.js), puis lib/gallery.ts vérifie le fichier :

TypeScript · lib/gallery.ts
// Allowed extensions, each with a check of the file's first bytes (its signature).
const SIGNATURES: Record<string, (bytes: Buffer) => boolean> = {
  ".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: Buffer, start: number, end: number) => bytes.subarray(start, end).toString("hex");
const ascii = (bytes: Buffer, start: number, end: number) => bytes.subarray(start, end).toString("latin1");

// True for "chair.jpg", false for ".chair.jpg", "chair.svg" or "chair".
export function isImageName(name: string): boolean {
  return !name.startsWith(".") && Object.hasOwn(SIGNATURES, extensionOf(name));
}

// 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: unknown): name is string {
  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)
  );
}
  • L’extension doit figurer sur la liste d’autorisation : .jpg, .jpeg, .png, .gif ou .webp. Le SVG est exclu, car il peut contenir du script.
  • Les premiers octets doivent correspondre à l’extension. Le File.type issu de formData() n’est jamais utilisé : c’est ce qu’a affirmé le navigateur, quoi que ce soit.
  • Le dossier doit être un chemin virtuel valide dont le chemin réel se trouve dans uploads/, comme dans les dossiers de galerie en Node.js.
  • Le nom stocké est généré, et writeFile s’exécute avec "wx" pour ne jamais écraser.

Une vérification de signature prouve comment un fichier commence, pas qu’il s’agit d’une image. Un fichier qui commence par des octets JPEG et se poursuit par du HTML passe. C’est pourquoi la façon dont les fichiers sont servis compte.

Servir les fichiers envoyés sans public/

Les fichiers envoyés ne peuvent pas aller dans public/, car next start ne sert que les fichiers présents au moment du build. Ils sont servis à la place par app/uploads/[...path]/route.ts, ce qui vous donne un endroit où placer des règles que public/ n’aurait pas :

TypeScript · lib/gallery.ts
// /uploads/<parts> -> the image file on disk, or a 404 GalleryError.
export async function resolveUpload(parts: string[]): Promise<string> {
  const name = parts.at(-1) ?? "";
  if (!isImageName(name) || /[\\/\0]/.test(name)) {
    throw new GalleryError(404, "Not found.");
  }
  const dir = await resolveFolder(UPLOADS_DIR, parts.slice(0, -1).join("/")).catch(() => {
    throw new GalleryError(404, "Not found.");
  });
  const file = path.join(dir, name);
  const info = await lstat(file).catch(() => null);
  if (!info || !info.isFile()) {
    throw new GalleryError(404, "Not found.");
  }
  return file;
}

Seuls des noms d’images sont servis, et uniquement depuis un dossier qui passe les mêmes vérifications de chemin. Nous avons essayé stray.html, .hidden.png, .secret/a.png, ..%2f..%2fpackage.png, ..%5cpackage.png et a%00.png : tous ont reçu un 404. Next.js décode les segments [...path] avant que la route ne les voie : %2f arrive donc comme un vrai / à l’intérieur d’un segment, et la vérification du nom le refuse. Une jonction Windows placée dans uploads/ a été refusée elle aussi, car la vérification compare les chemins réels. Chaque image part avec X-Content-Type-Options: nosniff : un navigateur conserve donc le type image/* et n’exécute pas un fichier comme du HTML.

Les trois gestionnaires exportent runtime = "nodejs". Ils ont besoin de node:fs, et la documentation du runtime Edge de Next.js est claire à ce sujet : on ne peut pas y lire ni y écrire dans le système de fichiers.

Ce qui reste à votre charge

  • Qui a le droit d’envoyer. L’exemple n’a pas de connexion. lib/root-for.ts est l’endroit où placer une racine par utilisateur, et la galerie partagée garde déjà uploads/users/ hors de portée à cette fin.
  • Qui a le droit de voir une image. /uploads/... est public. Ajoutez votre vérification de session à la route uploads si les images sont privées.
  • Les quotas, et le décodage des images avec une bibliothèque si vous devez savoir qu’il s’agit d’images complètes.

Tester

Démarrez le projet d’exemple de ce guide avec npm run build et npm start, puis :

Shell
curl -H "Origin: https://evil.example" -F action=create-folder -F folder= -F name=x http://localhost:5102/rte/gallery
{"error":"Cross-site request refused."}

printf '<?php echo 1; ?>' > fake.jpg
curl -F "file=@fake.jpg" http://localhost:5102/rte/upload
{"error":"The file content is not a valid JPG image."}

curl -F "file=@photo.jpg;filename=x.svg" http://localhost:5102/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}

Les statuts sont 403, 400 et 400. Dans la boîte de dialogue de la galerie, un fichier refusé affiche « Upload failed for <name>. » : la boîte de dialogue n’affiche pas le message du serveur.

Le même guide pour d’autres plateformes

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

À lire ensuite