Upload seguro de imagens no ASP.NET Web Forms
Uma pasta de upload dentro de um site ASP.NET é uma pasta da qual o IIS executa código sem hesitar. Se um arquivo .aspx algum dia for parar nela, por causa de um bug, de um FTP ou de uma mudança posterior, o IIS o compila e o executa. Esta página mostra as verificações que o exemplo Web Forms faz antes de gravar um único byte, e o bloco do web.config que mantém uploads/ estática mesmo que essas verificações sejam contornadas um dia. Testamos cada camada no IIS Express.
Antes de gravar qualquer coisa
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.");- Lista de extensões permitidas: .jpg, .jpeg, .png, .gif, .webp. Um
.svgé recusado porque pode conter script. Um.phpou.aspxé recusado, sejam quais forem seus bytes. - Tamanho: 4,000,000 bytes, respondido com um JSON 413.
- Magic bytes, não
ContentType.HttpPostedFile.ContentTypeé o que o navegador enviar. Enviamos bytes PNG rotulados comotext/html, e foi isso que ele informou. O exemplo o ignora e compara os primeiros bytes com a assinatura da extensão:
static bool SignatureMatches(string ext, byte[] d)
{
switch (ext)
{
case ".jpg":
case ".jpeg":
return d.Length >= 3 && d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF;
case ".png":
return d.Length >= 8 && d[0] == 0x89 && d[1] == 0x50 && d[2] == 0x4E && d[3] == 0x47
&& d[4] == 0x0D && d[5] == 0x0A && d[6] == 0x1A && d[7] == 0x0A;
case ".gif":
return d.Length >= 6 && d[0] == 0x47 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x38;
case ".webp":
return d.Length >= 12 && Encoding.ASCII.GetString(d, 0, 4) == "RIFF"
&& Encoding.ASCII.GetString(d, 8, 4) == "WEBP";
}
return false;
}Um arquivo chamado holiday.jpg que contém código-fonte PHP falha nessa verificação. Um JPEG real com algo anexado ao final ainda passa. A assinatura prova o tipo, não que o arquivo seja inofensivo, e é por isso que a própria pasta é bloqueada, como mostrado abaixo.
Dois detalhes do .NET Framework ficam no mesmo código. O nome do arquivo do cliente nunca é passado para Path.GetFileName, que lança ArgumentException com < ou | no .NET Framework 4.8. O handler de upload também lê Request.Unvalidated.Files, porque a validação de requisições cobre os nomes de arquivo: caso contrário, um arquivo chamado <script>x.png gerava um HTML 500. O nome é reduzido a a-z0-9- e recebe 8 caracteres hexadecimais aleatórios, então nada do que o cliente escolheu chega ao disco.
Onde ele pode ser gravado
O campo folder passa pelo mesmo ResolveFolder da galeria. Cada segmento precisa ser um nome de pasta válido, e o resultado de Path.GetFullPath precisa ficar dentro da raiz. Pastas da galeria no Web Forms mostra o código. No teste de contrato, um upload com folder=../ recebeu 400 e uma pasta inexistente recebeu 404.
uploads/ serve arquivos e não executa nada
O IIS escolhe o que executar pelo mapeamento de handlers, não pela pasta. Para verificar, colocamos uma página de uma linha, <%= 6*7 %>, na raiz do site como probe-root.aspx. Requisitá-la retornou 42: ela foi executada. O web.config do exemplo impede isso em uploads/:
<!-- 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><clear />mais apenasStaticFile. Antes de a lista de extensões ser adicionada, o mesmo.aspxe um.ashxdentro deuploads/responderam 404.3. Não havia handler para executá-los nem tipo MIME para enviá-los como texto.fileExtensions allowUnlisted="false". Com a lista de permissões,uploads/probe.aspxe um conteúdo HTML salvo comouploads/probe.txtresponderam 404.7, enquanto.pnge.webpforam servidos.X-Content-Type-Options: nosniffestava presente nas imagens servidas, então o navegador não tenta adivinhar outro tipo a partir do conteúdo.
A seção handlers vem bloqueada por padrão no applicationhost.config do IIS, mas o bloco <location path="" overrideMode="Allow"> desse arquivo a desbloqueia para os sites, e é por isso que um web.config pode alterá-la. Verificamos isso no modelo do IIS Express e não testamos em um servidor IIS completo. Em um host que a bloqueie, o site falha com um erro de configuração 500.19, em vez de executar scripts sem avisar.
Teste
Com o projeto de exemplo deste guia rodando na porta 5401:
echo "<script>alert(1)</script>" > fake.png
curl -F "file=@fake.png" http://localhost:5401/rte/upload
{"error":"The file is not a valid PNG image."}
curl -F "file=@fake.png;filename=x.svg" http://localhost:5401/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}As duas respostas são HTTP 400. Para ver o bloqueio da pasta, copie qualquer .aspx para uploads\ e faça uma requisição a ele. Você recebe a página 404.7 do IIS, e não a saída da página. Na caixa de diálogo da galeria, um arquivo rejeitado mostra "Upload failed for <name>.". A caixa de diálogo usa a própria mensagem em vez do texto do servidor.
O mesmo guia para outras plataformas
JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · MVC 5