Cargas de imágenes seguras en Next.js

Diagrama de una carga en el ejemplo de Next.js: navegador, el route handler, readForm(), las comprobaciones de lib/gallery.ts y la ruta de cargas, con los errores devueltos como 400 o 413

Next.js protege las Server Actions de formas que no se aplican a los route handlers, y las cargas de RichTextEditor van a route handlers. Esta página repasa lo que el ejemplo de Next.js añade por su cuenta: una comprobación de Origin contra los envíos entre sitios, las comprobaciones del archivo antes de escribir nada y una ruta que sirve las cargas sin convertir tu dominio en un alojamiento de archivos.

Los route handlers no tienen comprobación de Origin

La guía de seguridad de datos de Next.js dice que las Server Actions comparan el encabezado Origin con Host (o X-Forwarded-Host) y cancelan la solicitud si no coinciden. Los route handlers no hacen nada parecido. Antes de añadir una comprobación, enviamos POST /rte/gallery con Origin: https://evil.example y el ejemplo creó la carpeta: 200. Si tu inicio de sesión usa cookies, cualquier otro sitio podría hacer que el navegador de un usuario con sesión iniciada hiciera eso. El ejemplo ahora lo rechaza:

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

Los dos handlers POST llaman primero a checkOrigin(request), antes de leer el cuerpo. Comprobamos que Chromium envía Origin: http://localhost:5102 con las cargas del propio editor, así que pasan. Una solicitud sin Origin, como la de curl o la de código de servidor a servidor, también pasa. No es una solicitud de navegador entre sitios, y tu comprobación de inicio de sesión se le sigue aplicando. Origin: null, que envían las páginas en sandbox, se rechaza. Detrás de un proxy inverso que cambia el nombre de host, asegúrate de que establezca X-Forwarded-Host, o la comprobación rechazará también tus propias páginas.

Qué se comprueba antes de escribir

El diagrama del principio muestra el orden de una carga. Después de la comprobación de Origin, readForm() limita el tamaño del cuerpo (consulta límites de tamaño de carga de imágenes en Next.js) y luego lib/gallery.ts comprueba el archivo:

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)
  );
}
  • La extensión debe estar en la lista de permitidos: .jpg, .jpeg, .png, .gif o .webp. SVG se deja fuera porque puede llevar scripts.
  • Los primeros bytes deben coincidir con la extensión. File.type de formData() nunca se usa: es lo que haya declarado el navegador.
  • La carpeta debe ser una ruta virtual válida cuya ruta real esté dentro de uploads/, como en carpetas de la galería en Node.js.
  • El nombre guardado se genera, y writeFile se ejecuta con "wx" para que nunca sobrescriba.

Una comprobación de firma demuestra cómo empieza un archivo, no que sea una imagen. Un archivo que empieza con bytes JPEG y continúa con HTML la pasa. Por eso importa la forma en que se sirven los archivos.

Servir las cargas sin public/

Las cargas no pueden ir a public/, porque next start solo sirve los archivos que estaban ahí en el momento de la compilación. En su lugar las sirve app/uploads/[...path]/route.ts, lo que te da un lugar donde poner reglas que public/ no tendría:

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

Solo se sirven nombres de imagen, y solo desde una carpeta que pasa las mismas comprobaciones de ruta. Probamos stray.html, .hidden.png, .secret/a.png, ..%2f..%2fpackage.png, ..%5cpackage.png y a%00.png: todos recibieron 404. Next.js decodifica los segmentos de [...path] antes de que la ruta los vea, así que %2f llega como una / real dentro de un segmento, y la comprobación de nombres lo rechaza. También se rechazó una junction de Windows colocada en uploads/, porque la comprobación compara rutas reales. Cada imagen sale con X-Content-Type-Options: nosniff, así que el navegador mantiene el tipo image/* y no ejecuta un archivo como HTML.

Los tres handlers exportan runtime = "nodejs". Necesitan node:fs, y la documentación del runtime Edge de Next.js lo dice claramente: ahí no puedes leer ni escribir en el sistema de archivos.

Lo que el ejemplo deja en tus manos

  • Quién puede subir archivos. El ejemplo no tiene inicio de sesión. lib/root-for.ts es donde iría una raíz por usuario, y la galería compartida ya mantiene uploads/users/ fuera de alcance para eso.
  • Quién puede ver una imagen. /uploads/... es público. Añade tu comprobación de sesión a la ruta de cargas si las imágenes son privadas.
  • Las cuotas, y decodificar las imágenes con una biblioteca si necesitas saber que son imágenes completas.

Pruébalo

Inicia el proyecto de ejemplo de esta guía con npm run build y npm start, y luego:

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

Los estados son 403, 400 y 400. En el cuadro de diálogo de la galería, un archivo rechazado muestra "Upload failed for <name>.": el cuadro de diálogo no muestra el mensaje del servidor.

La misma guía para otras plataformas

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

Para seguir leyendo