Carpetas de la galería en ASP.NET MVC 5: explorar y crear de forma segura

El cuadro de diálogo Insert Gallery dentro de la carpeta blog del ejemplo de MVC 5, con cuatro imágenes y la ruta /blog

El cuadro de diálogo de la galería abre carpetas con GET /rte/gallery?folder=blog y las crea con un POST a la misma URL. En ASP.NET MVC 5, eso significa dos acciones con un mismo nombre, y un campo de formulario cuyo nombre también es un valor de ruta reservado de MVC. Esta página muestra cómo lo configura el ejemplo y cómo un nombre de carpeta que llega del navegador se convierte en una ruta dentro de uploads/ sin que se confíe nunca en él como ruta.

Una URL, dos acciones

La ruta rte/gallery apunta al nombre de acción Gallery. El método GET es Gallery(string folder), que se muestra en la guía de la galería. El método POST necesita otro nombre en C#, así que [ActionName("Gallery")] le devuelve el nombre de la ruta, y [HttpPost] permite que MVC elija entre los dos:

C# · Controllers/RteController.cs
[HttpPost]
[ActionName("Gallery")]
public ActionResult GalleryPost(string folder, string name)
{
    try
    {
        // Read "action" from the form directly: as an action parameter it would collide
        // with MVC's own {action} route value.
        if (Request.Unvalidated.Form["action"] != "create-folder")
            throw new RteException(400, "Unknown action.");
        RteFiles.CreateFolder(HttpContext, folder ?? "", name);
        return JsonStatus(200, RteFiles.Listing(HttpContext, folder ?? ""));
    }
    catch (RteException ex)
    {
        return JsonStatus(ex.Status, new { error = ex.Message });
    }
}

El campo llamado "action"

El cuadro de diálogo envía action=create-folder, folder (la carpeta superior) y name. Recibir string action como parámetro parece lo natural, pero action también es un valor de ruta propio de MVC. Lo probamos con un [HttpPost] Act(string action) desechable. Enviado sin campo action, recibió action=Act, el valor de ruta. Con el campo, recibió create-folder. El valor que obtienes depende tanto de la ruta como del formulario. El ejemplo lee en su lugar Request.Unvalidated.Form["action"], para que solo cuente el campo del formulario, y a un campo ausente se le responde con "Unknown action." (400).

folder y name son parámetros normales. Se enlazan sin validación de solicitudes porque el controlador tiene [ValidateInput(false)]. Sin él, name=<b>x dio una página HTML de error 500 de la validación de solicitudes. Con él, el nombre llega a las reglas propias del ejemplo y vuelve como un 400 en JSON.

Crear la carpeta

C# · RteFiles.cs
public static void CreateFolder(HttpContextBase context, string parentFolder, string name)
{
    StorageRoot root = GetRoot(context);
    string parentDir = ResolveFolder(root, parentFolder ?? "");
    string problem = NameProblem(name);
    if (problem != null)
        throw new RteException(400, problem);

    string target = Path.Combine(parentDir, name);
    if (Directory.Exists(target) || File.Exists(target))
        throw new RteException(409, "A folder named \"" + name + "\" already exists.");
    Directory.CreateDirectory(target);
}

Si todo va bien, la acción devuelve el listado de la carpeta superior, así que el cuadro de diálogo muestra la carpeta nueva de inmediato. Una carpeta existente da 409, y en NTFS Blog y blog cuentan como el mismo nombre.

La carpeta nunca se convierte en ruta tal como se escribió

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;
}

Cada parte de la carpeta virtual separada por / debe ser un nombre válido, lo que ya excluye .., \, : y las partes vacías. Después, la ruta unida pasa por Path.GetFullPath y debe seguir empezando por la raíz que devolvió Server.MapPath("~/uploads"). Las reglas de nombres son más estrictas que "letras, dígitos, espacio, -, _, ." por dos motivos con los que nos encontramos:

  • Se puede crear una carpeta llamada bin o App_Data, pero el filtrado de solicitudes de IIS trata esos nombres como segmentos ocultos en cualquier parte de una URL. Una imagen colocada en uploads/bin/ respondió 404.8. El ejemplo rechaza esos nombres, junto con los nombres de dispositivo de Windows como con y aux.txt.
  • Se rechazan los nombres que terminan en . o en un espacio, porque las reglas de nomenclatura de archivos de Microsoft dicen que Windows elimina esos caracteres, así que la carpeta del disco no coincidiría con el nombre del cuadro de diálogo.
C# · RteFiles.cs
// 1-64 chars of letters, digits, space, '-', '_', '.'; not starting with '.'.
// Windows extras: no trailing '.' or space (Windows silently drops them, so "a." would
// be the same folder as "a"), and no reserved device or IIS-hidden names.
public static bool IsValidName(string name)
{
    return NameProblem(name) == null;
}

static string NameProblem(string name)
{
    const string rule = "Folder names are 1-64 letters, digits, spaces, '-', '_' or '.', and cannot start with '.'.";
    if (string.IsNullOrEmpty(name) || name.Length > 64 || name[0] == '.')
        return rule;
    foreach (char c in name)
    {
        if (!char.IsLetterOrDigit(c) && c != ' ' && c != '-' && c != '_' && c != '.')
            return rule;
    }
    char last = name[name.Length - 1];
    if (last == '.' || last == ' ')
        return "Folder names cannot end with '.' or a space.";
    string lower = name.ToLowerInvariant();
    string stem = lower.Split('.')[0].Trim();
    if (Array.IndexOf(BlockedNames, lower) >= 0 || Array.IndexOf(BlockedNames, stem) >= 0)
        return "\"" + name + "\" is a reserved name on Windows or IIS.";
    return null;
}

Pruébalo

Con el proyecto de ejemplo de esta guía en ejecución en el puerto 5402, crea una carpeta blog en el cuadro de diálogo y luego:

Shell
curl -F action=create-folder -F folder=blog -F name=2026 http://localhost:5402/rte/gallery
{"currentFolder":"blog","currentFolderDisplay":"/blog","parentFolder":"","folders":[{"folder":"blog/2026","name":"2026"}],"images":[...]}

curl -F action=create-folder -F folder= -F name=bin http://localhost:5402/rte/gallery
{"error":"\"bin\" is a reserved name on Windows or IIS."}

HTTP 200 con la carpeta nueva en el listado y luego HTTP 400. Un PUT a /rte/gallery recibe el 404 normal de MVC, porque ninguna de las dos acciones acepta ese verbo. En el cuadro de diálogo, New folder pide un nombre y muestra la carpeta de inmediato.

La misma guía para otras plataformas

JavaScript · PHP · ASP.NET Core · Web Forms

Para seguir leyendo