Subir imágenes desde un editor de texto enriquecido en ASP.NET MVC 5

Una imagen insertada en RichTextEditor desde la galería del ejemplo de MVC 5, con la barra de herramientas completa encima

RichTextEditor envía cada imagen a tu servidor e inserta la URL que recibe de vuelta. En ASP.NET MVC 5, eso es una ruta, una acción de controlador y un HttpPostedFileBase. La forma obvia de escribir esa acción, con el archivo como parámetro, falla con nombres de archivo que no le gustan a la validación de solicitudes de ASP.NET. Esta página muestra la acción que funciona y la ruta que necesita, y cómo compilar el ejemplo sin Visual Studio.

La ruta

rte-files.js es el archivo para el navegador del proyecto de ejemplo. Envía a /rte/upload como multipart/form-data, con el archivo en un campo llamado file y un folder opcional, y espera {"url": "..."} de vuelta. En Global.asax.cs, dos rutas explícitas van antes de la ruta predeterminada {controller}/{action}/{id}:

C# · Global.asax.cs
// The two endpoints rte-files.js and the gallery dialog call.
routes.MapRoute("RteGallery", "rte/gallery", new { controller = "Rte", action = "Gallery" });
routes.MapRoute("RteUpload", "rte/upload", new { controller = "Rte", action = "Upload" });

La vista carga el editor y rte-files.js mediante Url.Content. Así, las rutas siguen funcionando cuando el sitio se ejecuta en un directorio virtual de IIS:

Razor · Views/Home/Index.cshtml
<link rel="stylesheet" href="@Url.Content("~/richtexteditor/rte_theme_default.css")" />
<script src="@Url.Content("~/richtexteditor/rte.js")"></script>
<script src="@Url.Content("~/richtexteditor/plugins/all_plugins.js")"></script>
<script>window.RTE_FILES_URL = "@Url.Content("~/rte")";</script>
<script src="@Url.Content("~/rte-files.js")"></script>

La acción

C# · Controllers/RteController.cs
[HttpPost]
public ActionResult Upload()
{
    try
    {
        // Not action parameters: MVC binds HttpPostedFileBase from the *validated*
        // Request.Files even under [ValidateInput(false)], so a file named "<b>.png"
        // would fail with an HTML 500 before this code runs.
        HttpPostedFileBase file = Request.Unvalidated.Files["file"];
        string folder = Request.Unvalidated.Form["folder"];
        string url = RteFiles.SaveUpload(HttpContext, file, folder);
        return JsonStatus(200, new { url = url });
    }
    catch (RteException ex)
    {
        return JsonStatus(ex.Status, new { error = ex.Message });
    }
}

El archivo no es un parámetro de la acción, y es intencionado. El controlador lleva [ValidateInput(false)], porque valida él mismo cada valor. Pero MVC enlaza un parámetro HttpPostedFileBase desde el Request.Files validado incluso con ese atributo. Con Upload(HttpPostedFileBase file, string folder), un archivo llamado <script>x.png recibió una página HTML de error 500 que decía "A potentially dangerous Request.Files value was detected from the client". Eso ocurre antes de que se ejecute la acción, así que es imposible devolver un error en JSON. Request.Unvalidated.Files["file"] devuelve el mismo HttpPostedFileBase sin esa comprobación. Con él, la misma solicitud se guarda como script-x-xxxxxxxx.png.

Los errores vuelven a través de una única función auxiliar, así que cada respuesta es JSON con el estado correcto:

C# · Controllers/RteController.cs
// Json() refuses GET unless told otherwise (JsonRequestBehavior.DenyGet is the default)
// and caps output at 2,097,152 characters; both are lifted here. Property names are
// written as declared, so the anonymous objects in RteFiles produce camelCase JSON.
JsonResult JsonStatus(int status, object data)
{
    Response.StatusCode = status;
    // Without this, IIS replaces 4xx bodies with its own error page for remote users.
    Response.TrySkipIisCustomErrors = true;
    // Forms authentication would turn a 401 into a 302 to the login page; keep the 401.
    Response.SuppressFormsAuthenticationRedirect = true;
    Response.Cache.SetCacheability(HttpCacheability.NoCache);
    Response.Cache.SetNoStore();
    return new JsonResult
    {
        Data = data,
        JsonRequestBehavior = JsonRequestBehavior.AllowGet,
        MaxJsonLength = int.MaxValue
    };
}

Qué comprueba el servidor

Las reglas están en RteFiles.cs. Solo usa HttpContextBase y HttpPostedFileBase, así que funciona igual desde cualquier controlador:

C# · RteFiles.cs
StorageRoot root = GetRoot(context);   // 401 here first in per-user mode
if (file == null || file.ContentLength == 0)
    throw new RteException(400, "No file was uploaded.");

// FileName is whatever the browser sent (old IE sends a full path). Don't feed it
// to Path.GetFileName: on .NET Framework that throws on characters like " < > |.
string clientName = file.FileName ?? "";
clientName = clientName.Substring(Math.Max(clientName.LastIndexOf('/'), clientName.LastIndexOf('\\')) + 1);
int dot = clientName.LastIndexOf('.');
string ext = dot >= 0 ? clientName.Substring(dot).ToLowerInvariant() : "";
if (Array.IndexOf(ImageExtensions, ext) < 0)
    throw new RteException(400, "Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.");
if (file.ContentLength > MaxUploadBytes)
    throw new RteException(413, "The image is larger than the 4 MB (4,000,000 bytes) limit.");

string dir = ResolveFolder(root, folder ?? "");

byte[] data = new byte[file.ContentLength];
Stream input = file.InputStream;
int read = 0;
while (read < data.Length)
{
    int n = input.Read(data, read, data.Length - read);
    if (n <= 0) break;
    read += n;
}
// file.ContentType is chosen by the browser (or the attacker), so look at the bytes.
if (read != data.Length || !SignatureMatches(ext, data))
    throw new RteException(400, "The file is not a valid " + ext.Substring(1).ToUpperInvariant() + " image.");

Las comprobaciones son: una extensión de imagen, como máximo 4,000,000 bytes y unos primeros bytes que coincidan con la extensión. ContentType se ignora, porque lo elige el navegador. El nombre de archivo del cliente se corta a mano en la última barra, porque Path.GetFileName de .NET Framework lanza una excepción con caracteres como < y |. Después, el archivo se escribe con un nombre aleatorio:

C# · RteFiles.cs
string baseName = SanitizeBaseName(dot >= 0 ? clientName.Substring(0, dot) : clientName);
for (int attempt = 0; ; attempt++)
{
    string stored = baseName + "-" + RandomHex(4) + ext;
    string path = Path.Combine(dir, stored);
    try
    {
        // CreateNew: never overwrite an existing file, even on a random-name collision.
        using (FileStream output = new FileStream(path, FileMode.CreateNew, FileAccess.Write))
            output.Write(data, 0, data.Length);
        return UrlFor(root, folder ?? "", stored);
    }
    catch (IOException)
    {
        if (attempt >= 4 || !File.Exists(path)) throw;
    }
}

Compilarlo sin Visual Studio

El ejemplo es un proyecto de estilo SDK que tiene como destino net48. Escribe en bin\, que es donde IIS espera los ensamblados, y hace referencia a Microsoft.AspNet.Mvc 5.3.0. También hace referencia a Microsoft.NETFramework.ReferenceAssemblies, así que no hace falta ningún paquete de destino. Partiendo de una carpeta sin bin ni obj, dotnet build restauró los paquetes y copió System.Web.Mvc.dll, Razor y WebPages en bin\, e IIS Express ejecutó el sitio. ASP.NET compila las vistas Razor en tiempo de ejecución, como en cualquier aplicación MVC 5. El controlador y RteFiles.cs también se pueden añadir a un proyecto de Visual Studio existente que use packages.config.

Pruébalo

Ejecuta el proyecto de ejemplo de esta guía con powershell -ExecutionPolicy Bypass -File run.ps1 -EditorPath <your richtexteditor folder>. Ejecuta dotnet build y luego inicia IIS Express en http://localhost:5402/. Después:

Shell
curl -F "file=@harbor.jpg" http://localhost:5402/rte/upload
{"url":"/uploads/harbor-fb0799c5.jpg"}

echo "<script>alert(1)</script>" > fake.png
curl -F "file=@fake.png" http://localhost:5402/rte/upload
{"error":"The file is not a valid PNG image."}

Las respuestas son HTTP 200 y HTTP 400. En el navegador, Insert Gallery y luego Upload envían la misma solicitud, e Insert pone la imagen en el texto. Con un archivo rechazado, el cuadro de diálogo muestra su propio "Upload failed for <name>." en lugar del mensaje del servidor.

La misma guía para otras plataformas

JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · Web Forms

Para seguir leyendo