Pastas da galeria no ASP.NET MVC 5: navegar e criar com segurança

A caixa de diálogo Insert Gallery dentro da pasta blog do exemplo em MVC 5, mostrando quatro imagens com o caminho /blog

A caixa de diálogo da galeria abre pastas com GET /rte/gallery?folder=blog e as cria com um POST para a mesma URL. No ASP.NET MVC 5, isso significa duas actions com o mesmo nome, e um campo de formulário cujo nome também é um valor de rota reservado do MVC. Esta página mostra como o exemplo configura isso, e como um nome de pasta vindo do navegador se torna um caminho dentro de uploads/ sem nunca ser tratado como confiável.

Uma URL, duas actions

A rota rte/gallery aponta para o nome de action Gallery. O método GET é Gallery(string folder), mostrado no guia da galeria. O método POST precisa de outro nome em C#, então [ActionName("Gallery")] devolve a ele o nome da rota, e [HttpPost] permite que o MVC escolha entre os dois:

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

O campo chamado "action"

A caixa de diálogo envia action=create-folder, folder (a pasta pai) e name. Receber string action como parâmetro parece natural, mas action também é um valor de rota do próprio MVC. Testamos isso com uma action descartável [HttpPost] Act(string action). Enviada sem o campo action, ela recebeu action=Act, o valor da rota. Com o campo, recebeu create-folder. O valor que você obtém depende da rota, e não só do formulário. Por isso, o exemplo lê Request.Unvalidated.Form["action"], de modo que só o campo do formulário conta, e um campo ausente recebe a resposta "Unknown action." (400).

folder e name são parâmetros comuns. Eles são vinculados sem a validação de requisições porque o controller tem [ValidateInput(false)]. Sem isso, name=<b>x gerava um HTML 500 da validação de requisições. Com isso, o nome chega às regras do próprio exemplo e volta como um JSON 400.

Como a pasta é criada

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

Em caso de sucesso, a action retorna a listagem da pasta pai, então a caixa de diálogo mostra a nova pasta na hora. Uma pasta que já existe gera 409, e no NTFS Blog e blog contam como o mesmo nome.

A pasta nunca vira um caminho do jeito que foi digitada

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 da pasta virtual separada por / precisa ser um nome válido, o que já exclui .., \, : e partes vazias. Em seguida, o caminho montado passa por Path.GetFullPath e ainda precisa começar com a raiz retornada por Server.MapPath("~/uploads"). As regras de nome são mais rígidas do que "letras, dígitos, espaço, -, _, ." por dois motivos que encontramos:

  • Uma pasta chamada bin ou App_Data pode ser criada, mas a filtragem de requisições do IIS trata esses nomes como segmentos ocultos em qualquer ponto de uma URL. Uma imagem colocada em uploads/bin/ respondeu 404.8. O exemplo recusa esses nomes, junto com nomes de dispositivo do Windows como con e aux.txt.
  • Nomes que terminam em . ou em espaço são recusados, porque as regras de nomenclatura de arquivos da Microsoft dizem que o Windows descarta esses caracteres, então a pasta no disco não corresponderia ao nome na caixa 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;
}

Teste

Com o projeto de exemplo deste guia rodando na porta 5402, crie uma pasta blog na caixa de diálogo e depois:

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 com a nova pasta na listagem e depois HTTP 400. Um PUT para /rte/gallery recebe o 404 comum do MVC, porque nenhuma das actions aceita esse verbo. Na caixa de diálogo, New folder pede um nome e mostra a pasta imediatamente.

O mesmo guia para outras plataformas

JavaScript · PHP · ASP.NET Core · Web Forms

Leia a seguir