Cargas de imágenes seguras en ASP.NET Web Forms

Diagrama de una carga en el ejemplo de Web Forms: navegador, asignación de handlers en web.config, comprobaciones de RteFiles, la carpeta uploads en el disco e IIS sirviéndola solo como archivos estáticos

Una carpeta de cargas dentro de un sitio ASP.NET es una carpeta desde la que IIS ejecutará código sin problemas. Si alguna vez acaba en ella un archivo .aspx, por un error, por FTP o por un cambio posterior, IIS lo compila y lo ejecuta. Esta página muestra las comprobaciones que hace el ejemplo de Web Forms antes de escribir un solo byte, y el bloque de web.config que mantiene uploads/ como contenido estático aunque algún día se eludan esas comprobaciones. Probamos cada capa en IIS Express.

Antes de escribir nada

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 extensiones permitidas: .jpg, .jpeg, .png, .gif, .webp. Un .svg se rechaza porque puede llevar scripts. Un .php o un .aspx se rechazan sean cuales sean sus bytes.
  • Tamaño: 4,000,000 bytes, con una respuesta 413 en JSON.
  • Bytes mágicos, no ContentType. HttpPostedFile.ContentType es lo que haya enviado el navegador. Enviamos bytes PNG etiquetados como text/html y eso es lo que decía. El ejemplo lo ignora y compara los primeros bytes con la firma de la extensión:
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;
}

Un archivo llamado holiday.jpg que contiene código fuente PHP no pasa esta comprobación. Un JPEG real con algo añadido al final sí la pasa. La firma demuestra el tipo, no que el archivo sea inofensivo; por eso la propia carpeta se bloquea más abajo.

En el mismo código hay dos detalles de .NET Framework. El nombre de archivo del cliente nunca se pasa a Path.GetFileName, que lanza ArgumentException con < o | en .NET Framework 4.8. El handler de carga también lee Request.Unvalidated.Files, porque la validación de solicitudes cubre los nombres de archivo: de lo contrario, un archivo llamado <script>x.png daba un HTML 500. El nombre se reduce a a-z0-9- y recibe 8 caracteres hexadecimales aleatorios, así que nada de lo que eligió el cliente llega al disco.

Dónde se puede escribir

El campo folder pasa por el mismo ResolveFolder que la galería. Cada segmento debe ser un nombre de carpeta válido, y el resultado de Path.GetFullPath debe quedarse dentro de la raíz. Carpetas de la galería en Web Forms muestra el código. En la prueba de contrato, una carga con folder=../ recibió 400, y una carpeta que no existe, 404.

uploads/ sirve archivos y no ejecuta nada

IIS decide qué ejecutar según la asignación de handlers, no según la carpeta. Para comprobarlo, pusimos una página de una línea, <%= 6*7 %>, en la raíz del sitio como probe-root.aspx. Al solicitarla devolvió 42: se ejecutó. El web.config del ejemplo lo impide para 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 /> y solo StaticFile. Antes de añadir la lista de extensiones, el mismo .aspx y un .ashx dentro de uploads/ respondieron 404.3. No había ningún handler para ejecutarlos ni ningún tipo MIME para enviarlos como texto.
  • fileExtensions allowUnlisted="false". Con la lista de permitidos, uploads/probe.aspx y una carga HTML guardada como uploads/probe.txt respondieron ambos 404.7, mientras que .png y .webp se sirvieron.
  • X-Content-Type-Options: nosniff estaba presente en las imágenes servidas, así que un navegador no deducirá un tipo distinto a partir del contenido.

La sección handlers está bloqueada de forma predeterminada en el applicationhost.config de IIS, pero su bloque <location path="" overrideMode="Allow"> la desbloquea para los sitios, y por eso un web.config puede cambiarla. Lo comprobamos en la plantilla de IIS Express y no lo probamos en un servidor IIS completo. En un host que la bloquee, el sitio falla con un error de configuración 500.19 en lugar de ejecutar scripts sin avisar.

Pruébalo

Con el proyecto de ejemplo de esta guía en ejecución en el puerto 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."}

Ambas respuestas son HTTP 400. Para ver el bloqueo de la carpeta, copia cualquier .aspx en uploads\ y solicítalo. Obtendrás la página 404.7 de IIS, no la salida de la página. En el cuadro de diálogo de la galería, un archivo rechazado muestra "Upload failed for <name>.". El cuadro de diálogo usa su propio mensaje en lugar del texto del servidor.

La misma guía para otras plataformas

JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · MVC 5

Para seguir leyendo