Cargas de imágenes seguras en Node.js

Diagrama de una carga en el ejemplo de Express: navegador, almacenamiento en memoria de multer, las comprobaciones de gallery.mjs, el archivo en el disco y express.static, con los errores devueltos como 400 o 413

Un endpoint de carga de imágenes es una forma de que cualquiera ponga un archivo en tu servidor y luego tu propio dominio lo sirva. Esta página repasa qué comprueba el ejemplo de Express antes de escribir un archivo, cómo sirve los archivos después y qué no pueden demostrar las comprobaciones, con las solicitudes que usamos para probar cada parte.

Qué comprueba el servidor antes de escribir

El diagrama del principio muestra el orden. multer lee el formulario en memoria (sus límites están en límites de tamaño de carga de imágenes en Node.js). Después, saveImage() ejecuta tres comprobaciones, y solo entonces se toca el disco:

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. La extensión debe ser .jpg, .jpeg, .png, .gif o .webp. Es una lista de permitidos, así que .svg, .html y .php se rechazan sin que el servidor tenga que conocerlos. SVG se deja fuera a propósito: puede contener scripts.
  2. Los primeros bytes deben coincidir con esa extensión: FF D8 FF para JPEG, la firma PNG de 8 bytes, GIF87a o GIF89a, y RIFF…WEBP. Un script renombrado a holiday.jpg falla aquí. req.file.mimetype de multer no se usa en absoluto: es el Content-Type que envió el navegador, y el navegador puede enviar cualquier cosa.
  3. La carpeta debe ser una ruta virtual válida dentro de la raíz de almacenamiento (consulta carpetas de la galería 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);
}

El nombre de archivo original nunca se convierte en el nombre guardado. Se reduce a a-z, 0-9 y -, se recorta a 40 caracteres y se le añaden ocho caracteres hexadecimales aleatorios. La extensión original sale de la lista de permitidos, en minúsculas. Escribir con el indicador "wx" significa que nunca se reemplaza un archivo existente.

Qué no pueden demostrar las comprobaciones

Una comprobación de firma te dice cómo empieza un archivo, no lo que viene después. Subimos un archivo llamado polyglot.jpg que empieza con los bytes JPEG FF D8 FF E0 y continúa con <html><script>alert(1)</script>. Se aceptó. Es lo esperado: demostrar que un archivo es una imagen válida completa significa decodificarla, con una biblioteca de imágenes como sharp, y volver a codificarla si quieres eliminar lo que tenga oculto. El ejemplo no lo hace. Lo que vuelve inofensivo un archivo así es la forma en que se sirve.

Cómo se sirven los archivos

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() solo deja pasar nombres de archivo de imagen cuya ruta real está dentro de uploads/. Pusimos a mano un stray.html en uploads/. Recibió un 404, igual que un enlace a una carpeta exterior.
  • dotfiles: "ignore" cubre lo que esa comprobación no cubre: un archivo como .secret/a.jpg dentro de una carpeta que empieza por punto. Recibió un 404. Express 5 lo hace de forma predeterminada. Express 4.22 sirvió uploads/.secret/a.txt con un 200 en nuestra prueba, así que el ejemplo escribe la opción de forma explícita.
  • X-Content-Type-Options: nosniff indica al navegador que respete el Content-Type que envió el servidor. El archivo políglota de arriba volvió como Content-Type: image/jpeg con nosniff, así que un navegador lo trata como una imagen y nunca ejecuta su script.

Lo que el ejemplo deja en tus manos

  • Quién puede subir archivos. No hay inicio de sesión. El servidor escucha en 127.0.0.1 de forma predeterminada (HOST lo cambia), así que nada fuera de tu máquina puede acceder a él mientras lo pruebas. Carpetas de imágenes por usuario en Node.js muestra dónde se conecta tu inicio de sesión.
  • CSRF. Express no tiene protección integrada para las dos rutas POST. Si tu inicio de sesión usa cookies, otro sitio puede hacer que el navegador de un usuario con sesión iniciada envíe solicitudes a ellas. Comprueba el encabezado Origin, como hace el ejemplo de Next.js, o usa un token CSRF.
  • Cuotas. Nada impide que un usuario llene el disco de 4 MB en 4 MB.

Pruébalo

Inicia el proyecto de ejemplo de esta guía con npm start y envíale los archivos de esta página:

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

Las dos primeras respuestas son HTTP 400. En el cuadro de diálogo de la galería, el mismo archivo falso muestra "Upload failed for fake.jpg." El cuadro de diálogo usa su propio texto, no el mensaje del servidor.

La misma guía para otras plataformas

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

Para seguir leyendo