Upload seguro de imagens no Razor Pages: antiforgery e origem
O Razor Pages protege todo handler POST com um token antiforgery, e a maioria dos desenvolvedores Razor Pages nem pensa nisso, porque a tag helper <form> adiciona o token por eles. Os uploads de imagem e as requisições de New folder do editor não são formulários: são XHRs que o próprio editor monta, e a versão atual não consegue acrescentar um token a elas. Esta página mostra como o exemplo mantém a página protegida, o que ele faz para essas requisições no lugar do token e o que seria necessário para usar o token mesmo assim.
O que o Razor Pages verifica
Em um POST para um handler de página, o Razor Pages compara dois valores: um cookie e um token de requisição, que por padrão chega como o campo de formulário __RequestVerificationToken ou o cabeçalho RequestVerificationToken. Imprimimos os padrões de AntiforgeryOptions no .NET 8: esses dois nomes e SameSite=Strict no cookie. No exemplo, o formulário Save da página passa na verificação, e um simples curl -d "Html=x" para a página recebe um 400.
// Razor Pages validates the antiforgery token on this POST by default; the <form> tag helper
// in Index.cshtml adds the hidden __RequestVerificationToken field. Save Html to your database here.
public void OnPost() => Message = $"Saved {Html?.Length ?? 0} characters of HTML.";Por que as requisições do editor não têm token
A requisição de upload vem de rte-files.js, e a de New folder, do plugin de galeria. Os dois criam o próprio FormData e o próprio XMLHttpRequest, e nenhum deles, na versão atual, tem uma opção para cabeçalhos ou campos extras. Alterar o XMLHttpRequest globalmente para injetar o token mudaria todas as requisições da página, então o exemplo não faz isso.
Restam, então, as três rotas como endpoints fora da página, com a validação de token desativada explicitamente. No .NET 8, isso é obrigatório para endpoints que fazem binding de IFormFile ou [FromForm]: sem .DisableAntiforgery(), um aplicativo Razor Pages respondia 500 quando app.UseAntiforgery() faltava, e 400 quando estava presente.
// Binding [FromForm] or IFormFile makes .NET 8 demand an antiforgery token on these endpoints.
// The editor's XHRs cannot send one, so it is switched off here and RteFiles checks the
// request's origin instead (see RequireSameOrigin). guide-notes.md has the details.
rte.MapPost("/gallery", (HttpContext http, [FromForm] string? action, [FromForm] string? folder, [FromForm] string? name) =>
Json(RteFiles.CreateFolder(http, action, folder, name)))
.DisableAntiforgery();A verificação que substitui o token
// 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.");
}Um cross-site request forgery é uma página de outro site fazendo o navegador do usuário enviar um POST para o seu. Os navegadores rotulam essas requisições: Sec-Fetch-Site diz se a requisição vem da mesma origem, do mesmo site ou de outro site, e Origin informa a origem da página. Um script no outro site não consegue definir nenhum dos dois cabeçalhos. Por isso, as rotas de upload e de New folder respondem 403 quando Sec-Fetch-Site é cross-site ou same-site, ou quando Origin é um host diferente. Os uploads feitos pela própria página do exemplo passaram no Chromium. Requisições do curl ou de código do servidor não enviam nenhum dos dois cabeçalhos e são permitidas, já que não podem levar os cookies do usuário.
Duas coisas para saber antes de depender disso:
- Atrás de um proxy reverso,
Request.Hostprecisa ser o host público. AdicioneUseForwardedHeaders, ou a verificação vai recusar as suas próprias páginas. same-siteé recusado de propósito: um subdomínio irmão comoblog.example.comé outro aplicativo, mesmo que compartilhe cookies.
Usando o token
Se rte-files.js e o plugin de galeria aceitassem cabeçalhos de requisição extras, a página poderia passar o token de requisição de IAntiforgery.GetAndStoreTokens(HttpContext).RequestToken no cabeçalho RequestVerificationToken. Aí você removeria .DisableAntiforgery(), adicionaria app.UseAntiforgery(), e os endpoints validariam o token como a página faz. A versão atual não tem essa opção, então o exemplo não consegue mostrar isso funcionando.
As outras verificações
A origem é a primeira de várias verificações. Depois dela, RteFiles.Save verifica a pasta, o tamanho, a extensão e os primeiros bytes e armazena o arquivo com um nome gerado, e a pasta é servida com apenas cinco content types e nosniff. Upload seguro de imagens no ASP.NET Core percorre todas elas; o código é o mesmo neste exemplo.
Teste
Execute o projeto de exemplo deste guia com dotnet run e, em seguida, finja ser outro site e faça POST para a página sem token:
curl -H "Origin: https://evil.example" -F "action=create-folder" -F "folder=" -F "name=x" http://localhost:5303/rte/gallery
{"error":"Cross-site request refused."}
curl -s -o /dev/null -w "%{http_code}\n" -d "Html=x" http://localhost:5303/
400O primeiro é o 403 do exemplo. O segundo é a verificação antiforgery do próprio Razor Pages na página.
O mesmo guia para outras plataformas
JavaScript · Next.js · PHP · ASP.NET Core · MVC 5 · Web Forms