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

Uma imagem inserida no RichTextEditor a partir da galeria do exemplo em Web Forms, com a barra de ferramentas completa acima dela

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:

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>

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:

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>

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

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

Duas linhas nele importam mais do que parece:

  • Request.Unvalidated.Files. A validação de requisições do ASP.NET também cobre Request.Files, e ela roda para handlers tanto quanto para páginas. Antes dessa mudança, um arquivo chamado <script>x.png recebia 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 como script-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 acessa Request.Files. Veja limites de tamanho no Web Forms para saber quando ele dispara.

O que o servidor verifica

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

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

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

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:

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

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

Leia a seguir