Upload seguro de imagens no ASP.NET Core: as verificações que importam

Diagrama: um upload vai do navegador, passando pelo Kestrel e pelo binding de IFormFile, até RteFiles.Save, que verifica origem, pasta, tamanho, tipo e bytes antes de gravar no disco

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

  1. Origem. Um POST que o navegador marca como vindo de outro site recebe 403.
  2. 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.
  3. 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.
  4. Extensão. Apenas .jpg, .jpeg, .png, .gif e .webp. SVG fica de fora porque um SVG servido pelo seu próprio site pode executar script.
  5. Bytes. Os primeiros bytes precisam corresponder à extensão.
  6. 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:

C# · RteFiles.cs
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.

C# · RteFiles.cs
// "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

C# · RteEndpoints.cs
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:

C# · RteFiles.cs
// 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.");
}
C# · Controllers/RteController.cs
[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:

Shell
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: nosniff

Os 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:

A caixa de diálogo Insert Gallery na pasta blog do exemplo ASP.NET Core com a mensagem Upload failed for holiday.jpg abaixo de quatro fotos
A galeria depois que o servidor recusou um script chamado holiday.jpg.

O mesmo guia para outras plataformas

JavaScript · Next.js · PHP · Razor Pages · MVC 5 · Web Forms

Leia a seguir