Enviar imagens de um editor de texto rico no ASP.NET Web Forms

Quando alguém solta, cola ou escolhe uma imagem no RichTextEditor, o navegador envia o arquivo para o seu servidor e espera a URL que vai entrar no documento. Em um site ASP.NET Web Forms, esse endpoint é um IHttpHandler. Esta página traz esse handler, as linhas do web.config que o colocam em /rte/upload e três detalhes do .NET Framework que transformam um upload normal em uma página HTML de erro 500 se você não os observar.
A página
O editor é carregado de /richtexteditor/. rte-files.js é um pequeno arquivo do navegador que vem no projeto de exemplo. Ele envia os arquivos para /rte/upload como multipart/form-data, com o arquivo em um campo chamado file, e lê {"url": "..."} de volta. Em Default.aspx, ResolveUrl mantém os caminhos corretos quando o site roda em um diretório virtual do IIS em vez de na raiz:
<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>Um handler em /rte/upload, sem .ashx na URL
A resposta habitual no Web Forms é um generic handler, upload.ashx. Aqui ele não funcionaria: rte-files.js monta sua URL como RTE_FILES_URL + "/upload", então nunca consegue chegar a um caminho que termine em .ashx. Em vez disso, o handler é uma classe simples em App_Code, e o web.config mapeia o caminho sem extensão para ela. Não é preciso código de roteamento nem uma entrada no 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>O type de um handler no web.config pode apontar para uma classe em App_Code. Ele não pode apontar para uma classe definida dentro de um arquivo .ashx. É por isso que a classe fica em App_Code. O mapeamento usa preCondition="integratedMode". Não testamos o pipeline clássico, que usa <system.web><httpHandlers> no lugar.
O 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);
}
}Duas linhas nele importam mais do que parece:
Request.Unvalidated.Files. A validação de requisições do ASP.NET também cobreRequest.Files, e ela roda para handlers tanto quanto para páginas. Antes dessa mudança, um arquivo chamado<script>x.pngrecebia uma página HTML de erro 500 dizendo "A potentially dangerous Request.Files value was detected from the client". Com ela, a mesma requisição é armazenada comoscript-x-xxxxxxxx.png. O nome do arquivo só é usado para montar um nome armazenado limpo.- O catch de
HttpException.<httpRuntime maxRequestLength>é aplicado quando o corpo é lido pela primeira vez, que é a linha que acessaRequest.Files. Veja limites de tamanho no Web Forms para saber quando ele dispara.
O que o servidor verifica
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.");As extensões permitidas são .jpg, .jpeg, .png, .gif e .webp. Os arquivos são limitados a 4,000,000 bytes, e os primeiros bytes precisam corresponder à extensão. HttpPostedFile.ContentType não tem nenhum papel. Quem o envia é o navegador, e quando enviamos bytes PNG como text/html, foi isso que o ContentType informou. O nome do arquivo do cliente também é cortado manualmente na última / ou \. No .NET Framework 4.8, Path.GetFileName("a<b>.png") e Path.GetFileName("a|b.png") lançam ArgumentException: Illegal characters in path, então um nome forjado viraria um 500.
Para onde vai o arquivo
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;
}
}O nome armazenado é o nome base limpo mais 8 caracteres hexadecimais aleatórios, por exemplo harbor-2e576eda.jpg. FileMode.CreateNew garante que um arquivo existente nunca seja sobrescrito.
O App_Code é compilado na primeira requisição pelo próprio csc.exe do framework. Nesta máquina, era a versão 4.8.9221.0, que "only supports language versions up to C# 5". Os arquivos App_Code do exemplo não usam $"", ?. nem membros =>, então compilam sem o pacote Roslyn CodeDom.
Teste
Execute o projeto de exemplo deste guia com powershell -ExecutionPolicy Bypass -File run.ps1 -EditorPath <your richtexteditor folder>. Ele inicia o IIS Express em http://localhost:5401/, sem etapa de build. Em seguida:
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."}A primeira resposta é HTTP 200 e a segunda é HTTP 400. No navegador, abra Insert Gallery e clique em Upload. A caixa de diálogo envia a mesma requisição por meio do rte-files.js, depois lista o arquivo armazenado, e Insert o coloca no texto. Um arquivo rejeitado mostra a mensagem própria da caixa de diálogo, "Upload failed for <name>.", e não o texto do servidor.
O mesmo guia para outras plataformas
JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · MVC 5