Upload seguro de imagens no ASP.NET MVC 5
Uma action de upload do MVC 5 recebe um arquivo de qualquer pessoa que consiga acessar a URL e o grava em uma pasta do seu site. Esta página percorre o que o RteController.Upload do exemplo verifica antes de gravar, e quais dessas verificações o model binding do MVC pode pular sem avisar. Ela também mostra como o web.config impede que uploads/ execute código, e quais arquivos de um projeto MVC o IIS serviria se você os implantasse. Todos os resultados aqui foram medidos no IIS Express.
Receber o arquivo sem um 500
[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 });
}
}[ValidateInput(false)] no controller desativa a validação de requisições do ASP.NET para os valores que o MVC vincula. Em vez dela, as regras do próprio exemplo verificam esses valores. Isso não cobre um parâmetro de action HttpPostedFileBase. Com Upload(HttpPostedFileBase file, string folder), um arquivo chamado <script>x.png ainda gerava um HTML 500 "A potentially dangerous Request.Files value was detected from the client". Isso não é uma brecha, porque a requisição foi recusada. Mas a recusa é uma página HTML que o seu tratamento de erros nunca vê. Ler Request.Unvalidated.Files["file"] retorna o mesmo HttpPostedFileBase, e então as verificações abaixo decidem.
O que o RteFiles verifica
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.");- Extensão: .jpg, .jpeg, .png, .gif ou .webp.
.svg,.phpe.aspxrecebem 400, seja qual for o conteúdo. - Tamanho: até 4,000,000 bytes. Arquivos maiores recebem um JSON 413.
- Conteúdo:
HttpPostedFileBase.ContentTypeé o que o navegador afirma, e bytes PNG enviados comotext/htmlchegaram exatamente assim. Por isso, os primeiros bytes são comparados com a assinatura da extensão (JPEGFF D8 FF, PNG89 50 4E 47 0D 0A 1A 0A, GIFGIF8, WEBPRIFF….WEBP). - Nome: o nome do arquivo do cliente é cortado na última barra manualmente, porque o
Path.GetFileNamedo .NET Framework lança exceção com<e|. Depois ele é reduzido aa-z0-9-e recebe 8 caracteres hexadecimais aleatórios.FileMode.CreateNewnunca sobrescreve um arquivo. - Pasta: cada segmento precisa ser um nome de pasta válido, e o caminho completo precisa ficar dentro de
Server.MapPath("~/uploads").folder=../recebeu 400 no teste de contrato.
// Turns a virtual folder ("", "products", "products/2026") into a directory inside the
// root. Every segment must be a valid folder name, which already rules out "..", ".",
// empty segments, "\", ":", drive letters, NUL and leading "/". The resolved path is
// then checked against the root anyway, so a mistake in the rules cannot escape it.
static string ResolveFolder(StorageRoot root, string folder)
{
if (folder.Length > 0)
{
foreach (string segment in folder.Split('/'))
{
if (!IsValidName(segment))
throw new RteException(400, "Invalid folder.");
}
}
string full = Path.GetFullPath(Path.Combine(root.Dir, folder.Replace('/', '\\')));
if (!(full.TrimEnd('\\') + "\\").StartsWith(root.Dir, StringComparison.OrdinalIgnoreCase))
throw new RteException(400, "Invalid folder.");
if (!Directory.Exists(full))
throw new RteException(404, "Folder not found.");
return full;
}uploads/ nunca executa código
Uma assinatura correta só prova que o arquivo começa como uma imagem. A pasta é a segunda trava:
<!-- Uploaded files are served as static files and nothing else. Clearing the handler list
means that even if a .aspx, .ashx or .config ever lands in uploads/, IIS will not run
it - it gets 404, not executed. The extension allow-list also refuses to serve
anything that is not an image (.html, .svg, .txt ...), wherever it came from. -->
<location path="uploads">
<system.webServer>
<handlers>
<clear />
<add name="StaticFile" path="*" verb="GET,HEAD" modules="StaticFileModule" resourceType="File" requireAccess="Read" />
</handlers>
<security>
<requestFiltering>
<fileExtensions allowUnlisted="false">
<add fileExtension=".jpg" allowed="true" />
<add fileExtension=".jpeg" allowed="true" />
<add fileExtension=".png" allowed="true" />
<add fileExtension=".gif" allowed="true" />
<add fileExtension=".webp" allowed="true" />
</fileExtensions>
</requestFiltering>
</security>
<httpProtocol>
<customHeaders>
<add name="X-Content-Type-Options" value="nosniff" />
</customHeaders>
</httpProtocol>
</system.webServer>
</location>Testamos isso no exemplo MVC. Uma página de uma linha <%= 6*7 %> na raiz do site retornou 42, ou seja, foi executada. O mesmo arquivo em uploads/ respondeu 404.7 (extensão não permitida). Com apenas a lista de handlers limpa e sem lista de permissões, um .aspx ali respondeu 404.3. Medimos isso no exemplo Web Forms, que usa o mesmo bloco. As imagens servidas levam X-Content-Type-Options: nosniff. O roteamento do MVC não interfere, porque os arquivos existentes em /uploads/ são servidos pelo IIS antes que qualquer rota seja tentada.
O que mais uma pasta MVC expõe
Com o site rodando a partir da pasta do projeto, como o run.ps1 faz, requisitamos alguns arquivos do projeto:
| URL | Resposta |
|---|---|
/RteFiles.cs, /RteMvc5Sample.csproj | 404.7 (o IIS recusa essas extensões) |
/web.config, /bin/RteMvc5Sample.dll | 404.8 (segmentos ocultos) |
/Views/Home/Index.cshtml | 404 (o BlockViewHandler em Views/web.config) |
/obj/project.assets.json | 200, com seus caminhos locais e a lista de pacotes |
Implante bin\, Views\, Global.asax, web.config e rte-files.js, e nunca obj\. A mesma regra de segmentos ocultos é o motivo pelo qual o exemplo recusa pastas de galeria chamadas bin ou App_Data: uma imagem em uploads/bin/ respondeu 404.8.
Teste
Com o projeto de exemplo deste guia rodando na porta 5402:
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."}
curl -F "file=@fake.png;filename=x.svg" http://localhost:5402/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}As duas respostas são HTTP 400 com JSON. Na caixa de diálogo da galeria, os mesmos arquivos geram 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 · Web Forms