Cargas de imágenes seguras en ASP.NET MVC 5

Diagrama de una carga en el ejemplo de MVC 5: navegador, ruta y RteController.Upload, Request.Unvalidated.Files, comprobaciones de RteFiles y la carpeta uploads en el disco

Una acción de carga de MVC 5 recibe un archivo de cualquiera que pueda llegar a la URL y lo escribe en una carpeta de tu sitio. Esta página repasa qué comprueba RteController.Upload del ejemplo antes de escribir, y cuáles de esas comprobaciones puede saltarse sin avisar el enlace de modelos de MVC. También explica cómo web.config impide que uploads/ ejecute código nunca, y qué archivos de un proyecto MVC serviría IIS si los desplegaras. Todos los resultados de aquí se midieron en IIS Express.

Obtener el archivo sin un 500

C# · Controllers/RteController.cs
[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)] en el controlador desactiva la validación de solicitudes de ASP.NET para los valores que enlaza MVC. En su lugar, las reglas propias del ejemplo comprueban esos valores. Eso no cubre un parámetro de acción HttpPostedFileBase. Con Upload(HttpPostedFileBase file, string folder), un archivo llamado <script>x.png seguía produciendo un HTML 500 "A potentially dangerous Request.Files value was detected from the client". No es un agujero, porque la solicitud se rechazó. Pero el rechazo es una página HTML que tu gestión de errores nunca ve. Leer Request.Unvalidated.Files["file"] da el mismo HttpPostedFileBase, y entonces deciden las comprobaciones de abajo.

Qué comprueba RteFiles

C# · 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.");
  • Extensión: .jpg, .jpeg, .png, .gif o .webp. .svg, .php y .aspx reciben 400 contengan lo que contengan.
  • Tamaño: hasta 4,000,000 bytes. Los archivos más grandes reciben un 413 en JSON.
  • Contenido: HttpPostedFileBase.ContentType es lo que declara el navegador, y unos bytes PNG enviados como text/html llegaron exactamente así. Por eso se comparan los primeros bytes con la firma de la extensión (JPEG FF D8 FF, PNG 89 50 4E 47 0D 0A 1A 0A, GIF GIF8, WEBP RIFF….WEBP).
  • Nombre: el nombre de archivo del cliente se corta a mano en la última barra, porque Path.GetFileName de .NET Framework lanza una excepción con < y |. Después se reduce a a-z0-9- y se le añaden 8 caracteres hexadecimales aleatorios. FileMode.CreateNew nunca sobrescribe un archivo.
  • Carpeta: cada segmento debe ser un nombre de carpeta válido, y la ruta completa debe quedarse dentro de Server.MapPath("~/uploads"). folder=../ recibió 400 en la prueba de contrato.
C# · RteFiles.cs
// 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 ejecuta código

Que la firma coincida solo demuestra que el archivo empieza como una imagen. La carpeta es el segundo cerrojo:

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>

Lo probamos con el ejemplo de MVC. Una página de una línea <%= 6*7 %> en la raíz del sitio devolvió 42, así que se ejecutó. El mismo archivo en uploads/ respondió 404.7 (extensión no permitida). Con solo la lista de handlers vaciada y sin lista de permitidos, un .aspx ahí respondió 404.3. Eso lo medimos con el ejemplo de Web Forms, que usa el mismo bloque. Las imágenes servidas llevan X-Content-Type-Options: nosniff. El enrutamiento de MVC no interfiere, porque IIS sirve los archivos existentes bajo /uploads/ antes de probar ninguna ruta.

Qué más expone una carpeta MVC

Con el sitio ejecutándose desde la carpeta del proyecto, como hace run.ps1, solicitamos algunos archivos del proyecto:

URLRespuesta
/RteFiles.cs, /RteMvc5Sample.csproj404.7 (IIS rechaza estas extensiones)
/web.config, /bin/RteMvc5Sample.dll404.8 (segmentos ocultos)
/Views/Home/Index.cshtml404 (el BlockViewHandler de Views/web.config)
/obj/project.assets.json200, con tus rutas locales y tu lista de paquetes

Despliega bin\, Views\, Global.asax, web.config y rte-files.js, y nunca obj\. La misma regla de segmentos ocultos es la razón por la que el ejemplo rechaza carpetas de galería llamadas bin o App_Data: una imagen en uploads/bin/ respondió 404.8.

Pruébalo

Con el proyecto de ejemplo de esta guía en ejecución en el puerto 5402:

Shell
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."}

Ambas respuestas son HTTP 400 con JSON. En el cuadro de diálogo de la galería, los mismos archivos producen el mensaje propio del cuadro de diálogo, "Upload failed for <name>.", no el texto del servidor.

La misma guía para otras plataformas

JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Web Forms

Para seguir leyendo