Subir imágenes desde un editor de texto enriquecido en ASP.NET Web Forms

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

Cuando alguien suelta, pega o elige una imagen en RichTextEditor, el navegador envía el archivo a tu servidor y espera la URL que pondrá en el documento. En un sitio ASP.NET Web Forms, ese endpoint es un IHttpHandler. Esta página te da ese handler, las líneas de web.config que lo colocan en /rte/upload y tres detalles de .NET Framework que, si los pasas por alto, convierten una carga normal en una página HTML de error 500.

La página

El editor se carga desde /richtexteditor/. rte-files.js es un archivo corto para el navegador del proyecto de ejemplo. Envía los archivos a /rte/upload como multipart/form-data, con el archivo en un campo llamado file, y lee {"url": "..."} de vuelta. En Default.aspx, ResolveUrl mantiene correctas las rutas cuando el sitio se ejecuta en un directorio virtual de IIS en lugar de en la raíz:

ASP.NET · Default.aspx
<link rel="stylesheet" href="<%= ResolveUrl("~/richtexteditor/rte_theme_default.css") %>" />
<script src="<%= ResolveUrl("~/richtexteditor/rte.js") %>"></script>
<script src="<%= ResolveUrl("~/richtexteditor/plugins/all_plugins.js") %>"></script>
<script>window.RTE_FILES_URL = "<%= ResolveUrl("~/rte") %>";</script>
<script src="<%= ResolveUrl("~/rte-files.js") %>"></script>

Un handler en /rte/upload, sin .ashx en la URL

La respuesta habitual en Web Forms es un generic handler, upload.ashx. Aquí no funcionaría: rte-files.js construye su URL como RTE_FILES_URL + "/upload", así que nunca puede llegar a una ruta que termine en .ashx. En su lugar, el handler es una clase normal en App_Code, y web.config asocia la ruta sin extensión con ella. No hace falta código de enrutamiento ni ninguna entrada en Global.asax:

XML · web.config
<handlers>
  <!-- The two endpoints. The classes are in App_Code; ASP.NET compiles them on first use. -->
  <add name="RteGallery" path="rte/gallery" verb="*" type="RteGalleryHandler" preCondition="integratedMode" />
  <add name="RteUpload" path="rte/upload" verb="*" type="RteUploadHandler" preCondition="integratedMode" />
</handlers>

El type de un handler en web.config puede nombrar una clase de App_Code. No puede nombrar una clase definida dentro de un archivo .ashx. Por eso la clase está en App_Code. La asignación usa preCondition="integratedMode". No probamos la canalización clásica, que usa en su lugar <system.web><httpHandlers>.

El handler

C# · App_Code/RteUploadHandler.cs
public void ProcessRequest(HttpContext context)
{
    try
    {
        if (context.Request.HttpMethod != "POST")
        {
            context.Response.AppendHeader("Allow", "POST");
            throw new RteException(405, "Use POST.");
        }

        HttpPostedFile file;
        string folder;
        try
        {
            // Reading the body is where ASP.NET enforces <httpRuntime maxRequestLength>.
            // Unvalidated: request validation also covers Request.Files, and a file
            // named "<b>.png" would otherwise fail with an HTML 500 page.
            file = context.Request.Unvalidated.Files["file"];
            folder = context.Request.Unvalidated.Form["folder"];
        }
        catch (HttpException ex)
        {
            if (ex.WebEventCode != System.Web.Management.WebEventCodes.RuntimeErrorPostTooLarge) throw;
            throw new RteException(413, "The upload is larger than the server allows.");
        }

        string url = RteFiles.SaveUpload(context, file, folder);
        RteFiles.WriteJson(context, 200, new { url = url });
    }
    catch (RteException ex)
    {
        RteFiles.WriteError(context, ex.Status, ex.Message);
    }
}

Dos de sus líneas importan más de lo que parece:

  • Request.Unvalidated.Files. La validación de solicitudes de ASP.NET también cubre Request.Files, y se ejecuta tanto para los handlers como para las páginas. Antes de este cambio, un archivo llamado <script>x.png recibía una página HTML de error 500 que decía "A potentially dangerous Request.Files value was detected from the client". Con él, la misma solicitud se guarda como script-x-xxxxxxxx.png. El nombre del archivo solo se usa para construir un nombre guardado limpio.
  • El catch de HttpException. <httpRuntime maxRequestLength> se aplica cuando se lee el cuerpo por primera vez, que es la línea que accede a Request.Files. Consulta límites de tamaño en Web Forms para saber cuándo se dispara.

Qué comprueba el servidor

C# · App_Code/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 extensiones permitidas son .jpg, .jpeg, .png, .gif y .webp. Los archivos están limitados a 4,000,000 bytes, y los primeros bytes deben coincidir con la extensión. HttpPostedFile.ContentType no interviene. Lo envía el navegador, y cuando enviamos bytes PNG como text/html, eso es lo que decía ContentType. El nombre de archivo del cliente también se corta a mano en la última / o \. En .NET Framework 4.8, Path.GetFileName("a<b>.png") y Path.GetFileName("a|b.png") lanzan ArgumentException: Illegal characters in path, así que, de lo contrario, un nombre manipulado se convertiría en un 500.

Dónde se guarda el archivo

C# · App_Code/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;
    }
}

El nombre guardado es el nombre base saneado más 8 caracteres hexadecimales aleatorios, por ejemplo harbor-2e576eda.jpg. FileMode.CreateNew significa que nunca se sobrescribe un archivo existente.

App_Code se compila en la primera solicitud con el propio csc.exe del framework. En esta máquina era la versión 4.8.9221.0, que "only supports language versions up to C# 5". Los archivos App_Code del ejemplo no usan $"", ?. ni miembros =>, así que se compilan sin el paquete Roslyn CodeDom.

Pruébalo

Ejecuta el proyecto de ejemplo de esta guía con powershell -ExecutionPolicy Bypass -File run.ps1 -EditorPath <your richtexteditor folder>. Inicia IIS Express en http://localhost:5401/, sin paso de compilación. Después:

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

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

La primera respuesta es HTTP 200 y la segunda HTTP 400. En el navegador, abre Insert Gallery y haz clic en Upload. El cuadro de diálogo envía la misma solicitud a través de rte-files.js, luego muestra el archivo guardado, e Insert lo pone en el texto. Un archivo rechazado muestra el mensaje propio del cuadro de diálogo, "Upload failed for <name>.", no el texto del servidor.

La misma guía para otras plataformas

JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · MVC 5

Para seguir leyendo