Enviar imagens de um editor de texto rico no ASP.NET MVC 5

O RichTextEditor envia cada imagem para o seu servidor e insere a URL que volta. No ASP.NET MVC 5, isso é uma rota, uma action de controller e um HttpPostedFileBase. A forma óbvia de escrever essa action, com o arquivo como parâmetro, falha com nomes de arquivo que a validação de requisições do ASP.NET não aceita. Esta página mostra a action que funciona e a rota de que ela precisa, e como fazer o build do exemplo sem o Visual Studio.
A rota
rte-files.js é o arquivo do navegador que vem no projeto de exemplo. Ele envia para /rte/upload como multipart/form-data, com o arquivo em um campo chamado file e um folder opcional, e espera receber {"url": "..."} de volta. Em Global.asax.cs, duas rotas explícitas vêm antes da rota padrão {controller}/{action}/{id}:
// 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" });A view carrega o editor e o rte-files.js por meio de Url.Content. Assim, os caminhos continuam funcionando quando o site roda em um diretório virtual do IIS:
<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>A action
[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 });
}
}O arquivo não é um parâmetro da action, e isso é proposital. O controller tem [ValidateInput(false)], porque ele mesmo valida cada valor. Mas o MVC vincula um parâmetro HttpPostedFileBase a partir do Request.Files validado, mesmo com esse atributo. Com Upload(HttpPostedFileBase file, string folder), um arquivo chamado <script>x.png recebeu uma página HTML de erro 500 dizendo "A potentially dangerous Request.Files value was detected from the client". Isso acontece antes de a action rodar, então é impossível devolver um erro em JSON. Request.Unvalidated.Files["file"] retorna o mesmo HttpPostedFileBase sem essa verificação. Com ele, a mesma requisição é armazenada como script-x-xxxxxxxx.png.
Os erros voltam por um único helper, então toda resposta é JSON com o código de status correto:
// 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
};
}O que o servidor verifica
As regras ficam em RteFiles.cs. Ele usa apenas HttpContextBase e HttpPostedFileBase, então funciona da mesma forma a partir de qualquer controller:
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 verificações são: uma extensão de imagem, no máximo 4,000,000 bytes e primeiros bytes que correspondam à extensão. O ContentType é ignorado, porque quem o escolhe é o navegador. O nome do arquivo enviado pelo cliente é cortado na última barra manualmente, porque o Path.GetFileName do .NET Framework lança exceção com caracteres como < e |. Em seguida, o arquivo é gravado com um nome aleatório:
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;
}
}Build sem o Visual Studio
O exemplo é um projeto no estilo SDK que tem como alvo net48. Ele grava em bin\, que é onde o IIS espera os assemblies, e referencia Microsoft.AspNet.Mvc 5.3.0. Ele também referencia Microsoft.NETFramework.ReferenceAssemblies, então nenhum targeting pack é necessário. Partindo de uma pasta sem bin nem obj, o dotnet build restaurou os pacotes e copiou System.Web.Mvc.dll, Razor e WebPages para bin\, e o IIS Express executou o site. As views Razor são compiladas pelo ASP.NET em tempo de execução, como em qualquer aplicativo MVC 5. O controller e o RteFiles.cs também podem ser colocados em um projeto do Visual Studio existente que use packages.config.
Teste
Execute o projeto de exemplo deste guia com powershell -ExecutionPolicy Bypass -File run.ps1 -EditorPath <your richtexteditor folder>. Ele executa o dotnet build e depois inicia o IIS Express em http://localhost:5402/. Em seguida:
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."}As respostas são HTTP 200 e HTTP 400. No navegador, Insert Gallery e depois Upload enviam a mesma requisição, e Insert coloca a imagem no texto. Para um arquivo rejeitado, a caixa de diálogo mostra sua própria mensagem "Upload failed for <name>." em vez da mensagem do servidor.
O mesmo guia para outras plataformas
JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · Web Forms