Upload seguro de imagens no ASP.NET Core: as verificações que importam
Uma rota de upload aceita arquivos de qualquer pessoa que consiga alcançá-la, então cada coisa que pode dar errado precisa da própria verificação. Esta página percorre as verificações do exemplo ASP.NET Core na ordem em que uma requisição passa por elas: de onde veio a requisição, a pasta, o tamanho, o tipo, os bytes, o nome armazenado e como o arquivo é servido depois. Cada uma vem com a resposta que obtivemos quando a quebramos de propósito.
As verificações, em ordem
- Origem. Um POST que o navegador marca como vindo de outro site recebe 403.
- Pasta. A pasta virtual precisa ser resolvida dentro de
uploads/e existir (400 ou 404). Pastas da galeria no ASP.NET Core trata dessa verificação. - Tamanho. Acima de 4,000,000 bytes, recebe 413. Requisições muito maiores são barradas antes, pelos limites descritos em Limites de tamanho de upload no ASP.NET Core.
- Extensão. Apenas
.jpg,.jpeg,.png,.gife.webp. SVG fica de fora porque um SVG servido pelo seu próprio site pode executar script. - Bytes. Os primeiros bytes precisam corresponder à extensão.
- Nome. O nome armazenado é gerado, então o nome enviado pelo cliente nunca chega ao disco.
Não confie em ContentType nem em FileName
IFormFile.ContentType é o Content-Type da parte do multipart, que o cliente escreve. O navegador o deduz a partir do nome do arquivo, e o curl envia o que mandarem. O exemplo o ignora e, em vez disso, lê os doze primeiros bytes:
static bool SignatureMatches(string ext, byte[] head) => ext switch
{
".jpg" or ".jpeg" => head[0] == 0xFF && head[1] == 0xD8 && head[2] == 0xFF,
".png" => head.AsSpan(0, 8).SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }),
".gif" => head.AsSpan(0, 4).SequenceEqual("GIF8"u8),
".webp" => head.AsSpan(0, 4).SequenceEqual("RIFF"u8) && head.AsSpan(8, 4).SequenceEqual("WEBP"u8),
_ => false,
};Isso barra um script renomeado para holiday.jpg. Não prova que o arquivo é uma imagem válida: um arquivo pode começar como um PNG e conter outra coisa mais adiante. O que torna isso inofensivo aqui é a forma como o arquivo é servido (veja abaixo). Se precisar de mais, decodifique e recodifique cada upload com uma biblioteca de imagens. O exemplo não faz isso, para não ter dependências.
// "Red Square.png" -> "red-square"
static string SafeBaseName(string fileName)
{
var name = Regex.Replace(Path.GetFileNameWithoutExtension(fileName).ToLowerInvariant(), "[^a-z0-9]+", "-").Trim('-');
if (name.Length > 40) name = name[..40].TrimEnd('-');
return name.Length == 0 ? "image" : name;
}O nome base é convertido para minúsculas e reduzido a a-z, 0-9 e -, oito caracteres hexadecimais aleatórios de RandomNumberGenerator são acrescentados, e o arquivo é aberto com FileMode.CreateNew, que falha em vez de sobrescrever.
Servindo os arquivos com segurança
var uploads = RteFiles.UploadsFolder(app.Environment);
Directory.CreateDirectory(uploads);
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(uploads),
RequestPath = "/uploads",
ContentTypeProvider = new FileExtensionContentTypeProvider(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png",
[".gif"] = "image/gif", [".webp"] = "image/webp",
}),
OnPrepareResponse = ctx => ctx.Context.Response.Headers.XContentTypeOptions = "nosniff",
});O FileExtensionContentTypeProvider padrão do ASP.NET Core conhece .html, .svg e .js, então um UseStaticFiles padrão na pasta de upload os serviria como páginas e scripts. O provider do exemplo conhece apenas os cinco tipos de imagem. Qualquer outra coisa na pasta recebe 404, porque ServeUnknownFileTypes continua desativado. X-Content-Type-Options: nosniff diz ao navegador para usar o tipo declarado e não adivinhar.
Requisições cross-site sem token
A resposta habitual do ASP.NET Core a cross-site request forgery é um token antiforgery. As requisições de upload e de New folder do editor são XHRs montadas por rte-files.js e pelo plugin de galeria, e a versão atual não tem como acrescentar a elas um cabeçalho ou um campo. Por isso, as rotas desativam explicitamente a validação de token e, em vez dela, verificam de onde veio a requisição:
// CSRF: the editor's upload and create-folder requests are XHRs that carry no antiforgery token,
// so these POSTs refuse requests that a browser marks as coming from another site instead.
// Browsers send Sec-Fetch-Site and Origin on these requests; tools like curl send neither.
static void RequireSameOrigin(HttpRequest request)
{
var site = request.Headers["Sec-Fetch-Site"].ToString();
var origin = request.Headers.Origin.ToString();
var foreign = site is "cross-site" or "same-site"
|| (origin.Length > 0 && !(Uri.TryCreate(origin, UriKind.Absolute, out var o)
&& string.Equals(o.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase)));
if (foreign) throw new RteError(403, "Cross-site request refused.");
}[ApiController]
[Route("rte")]
// [ApiController] actions are not antiforgery-checked unless the app adds a global
// AutoValidateAntiforgeryTokenAttribute. The editor's XHRs cannot send a token, so this opts out
// explicitly; RteFiles checks the request's origin instead.
[IgnoreAntiforgeryToken]Os navegadores enviam Sec-Fetch-Site e Origin nessas requisições, e uma página de outro site não consegue alterá-los. O curl e o código do lado do servidor não enviam nenhum dos dois e são liberados: eles não têm os cookies do usuário. Atrás de um proxy reverso que altera o cabeçalho Host, adicione UseForwardedHeaders para que Request.Host seja o host público. Na versão Minimal API, a mesma escolha é .DisableAntiforgery(). No .NET 8 ela é obrigatória, como Enviar imagens de um editor de texto rico no ASP.NET Core explica.
Nada disso decide quem pode fazer upload. Adicione [Authorize] ou .RequireAuthorization() quando o seu aplicativo tiver login.
Teste
Execute o projeto de exemplo deste guia com dotnet run e envie a ele um script com nome de foto, um SVG e uma requisição que diz vir de outro site:
echo "not a picture" > holiday.jpg
curl -F "file=@holiday.jpg" http://localhost:5302/rte/upload
{"error":"The file is not a valid .jpg image."}
curl -F "file=@logo.svg" http://localhost:5302/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}
curl -H "Origin: https://evil.example" -F "file=@photo.jpg" http://localhost:5302/rte/upload
{"error":"Cross-site request refused."}
curl -I http://localhost:5302/uploads/blog/photo-65fc24fe.jpg
HTTP/1.1 200 OK
Content-Type: image/jpeg
X-Content-Type-Options: nosniffOs dois primeiros são 400; o terceiro, 403. Na galeria, o mesmo holiday.jpg falso também é recusado, mas a caixa de diálogo mostra a própria mensagem genérica, e não a do servidor:

O mesmo guia para outras plataformas
JavaScript · Next.js · PHP · Razor Pages · MVC 5 · Web Forms