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

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:
[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
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
// 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
binouApp_Datapode 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 emuploads/bin/respondeu 404.8. O exemplo recusa esses nomes, junto com nomes de dispositivo do Windows comoconeaux.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.
// 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:
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