Upload seguro de imagens no ASP.NET Web Forms

Diagrama de um upload no exemplo em Web Forms: navegador, mapeamento de handler no web.config, as verificações de RteFiles, a pasta uploads no disco e o IIS servindo-a somente como arquivos estáticos

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

C# · App_Code/RteFiles.cs
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 .php ou .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 como text/html, e foi isso que ele informou. O exemplo o ignora e compara os primeiros bytes com a assinatura da extensão:
C# · App_Code/RteFiles.cs
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/:

XML · web.config
<!-- 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 apenas StaticFile. Antes de a lista de extensões ser adicionada, o mesmo .aspx e um .ashx dentro de uploads/ 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.aspx e um conteúdo HTML salvo como uploads/probe.txt responderam 404.7, enquanto .png e .webp foram servidos.
  • X-Content-Type-Options: nosniff estava 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:

Shell
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

Leia a seguir