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

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:
<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:
<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
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 cubreRequest.Files, y se ejecuta tanto para los handlers como para las páginas. Antes de este cambio, un archivo llamado<script>x.pngrecibí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 comoscript-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 aRequest.Files. Consulta límites de tamaño en Web Forms para saber cuándo se dispara.
Qué comprueba el servidor
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
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:
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