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

A caixa de diálogo Insert Gallery dentro da pasta blog do exemplo em ASP.NET Core, mostrando quatro fotos com o caminho /blog, a primeira selecionada

O botão New folder da galeria e os blocos de pasta enviam um nome de pasta do navegador para o seu servidor. No ASP.NET Core, esse nome precisa virar um caminho no disco sem nunca sair de uploads/, e o Windows traz as próprias surpresas sobre o que é um nome de pasta. Esta página mostra a rota de criação de pasta, as regras de nome e a verificação de caminho, com as respostas que obtivemos para entradas inválidas.

A rota

O New folder envia por POST action=create-folder, a pasta aberta e o novo nome para a URL da galeria, como multipart/form-data:

C# · Controllers/RteController.cs
// POST /rte/gallery   action=create-folder, folder, name
[HttpPost("gallery")]
public IActionResult CreateFolder([FromForm(Name = "action")] string? op, [FromForm] string? folder, [FromForm] string? name) =>
    Send(RteFiles.CreateFolder(HttpContext, op, folder, name));

O parâmetro em C# se chama op e é vinculado com [FromForm(Name = "action")], o que o mantém separado do valor de rota action do próprio MVC. O método responde com a listagem da pasta pai, então a caixa de diálogo mostra a nova pasta na hora:

C# · RteFiles.cs
// POST /rte/gallery   action=create-folder, folder=<parent>, name=<new folder>
public static RteResult CreateFolder(HttpContext http, string? action, string? folder, string? name) => Run(() =>
{
    RequireSameOrigin(http.Request);
    if (action != "create-folder") throw new RteError(400, "Unknown action.");
    var root = RootFor(http);
    folder ??= "";
    var parent = Resolve(root, folder);
    if (!IsValidName(name))
        throw new RteError(400, "Use 1-64 letters, digits, spaces, '-', '_' or '.', not starting with '.'.");
    var path = Path.Combine(parent, name!);
    if (Directory.Exists(path) || File.Exists(path))
        throw new RteError(409, "A folder with that name already exists.");
    Directory.CreateDirectory(path);
    return Listing(root, folder, parent); // the parent's listing, so the new folder shows
});

No Windows, Directory.Exists não diferencia maiúsculas de minúsculas, então Blog ao lado de uma blog existente recebe o mesmo 409 que uma duplicata exata.

Nomes de pasta

C# · RteFiles.cs
// Folder names: 1-64 ASCII letters, digits, space, - _ . ; not starting with "." (no "..", no hidden
// folders). Also refused because Windows mangles them: a trailing "." or space, a leading space,
// and device names such as "con" or "nul.txt".
public static bool IsValidName(string? name) =>
    name is { Length: >= 1 and <= 64 }
    && name.All(c => char.IsAsciiLetterOrDigit(c) || c is ' ' or '-' or '_' or '.')
    && !name.StartsWith('.') && !name.StartsWith(' ')
    && !name.EndsWith('.') && !name.EndsWith(' ')
    && !WindowsDeviceNames.Contains(name.Split('.')[0]);

Permitir apenas letras ASCII, dígitos, espaço, -, _ e . exclui /, \, :, % e NUL de uma vez. As outras regras vêm da forma como o Windows trata nomes:

  • Um ponto ou espaço no final. Path.GetFullPath(@"C:\app\uploads\photos.") retornou C:\app\uploads\photos. Uma pasta criada como photos. seria listada como photos, e os dois nomes apontariam para a mesma pasta.
  • Nomes de dispositivo. con, nul, com1 e similares, com ou sem extensão, são recusados. name=con recebe um 400.
  • Um ponto no início. Isso barra .. e pastas ocultas como .git. A listagem também ignora pastas existentes que violam as regras.

De uma pasta virtual a um caminho no disco

O navegador só envia caminhos virtuais: "", blog, blog/2026. Todas as rotas os resolvem da mesma forma:

C# · RteFiles.cs
// A virtual folder ("", "products", "products/2026") becomes a disk path inside the root.
// Every segment must be a valid folder name, so "..", "C:", "\", "%2e%2e" and NUL never reach the disk.
static string Resolve(RteRoot root, string folder)
{
    var rootPath = Path.GetFullPath(root.Disk);
    Directory.CreateDirectory(rootPath);
    if (folder.Length == 0) return rootPath;

    var segments = folder.Split('/');
    if (!segments.All(IsValidName)) throw new RteError(400, "Invalid folder.");

    // Second line of defence: the resolved path must still be inside the root. Compare against the
    // root WITH a trailing separator, or "C:\app\uploads-old" would pass as inside "C:\app\uploads".
    var path = Path.GetFullPath(Path.Combine([rootPath, .. segments]));
    if (!path.StartsWith(Path.TrimEndingDirectorySeparator(rootPath) + Path.DirectorySeparatorChar, StringComparison.Ordinal))
        throw new RteError(400, "Invalid folder.");
    if (!Directory.Exists(path)) throw new RteError(404, "Folder not found.");
    return path;
}

Cada segmento precisa passar pelas regras de nome, então .., C:, %2e%2e e uma / inicial são recusados antes de qualquer caminho ser montado. A verificação de contenção depois de Path.GetFullPath é uma segunda linha de defesa, e os dois detalhes dela importam no .NET:

  • @"C:\app\uploads-old\x.png".StartsWith(@"C:\app\uploads") é true. Compare com a raiz incluindo um separador no final, como o código faz.
  • Path.Combine(@"C:\app\uploads", @"C:\Windows") retorna C:\Windows, e Path.Combine(@"C:\app\uploads", @"\Windows") retorna \Windows. Um argumento com raiz descarta tudo o que vem antes dele. Nunca passe um valor da requisição para Path.Combine sem verificá-lo.

GetFullPath resolve .., mas não junctions nem links simbólicos. Uma junction que alguém com acesso ao servidor coloque dentro de uploads/ ainda pode ser navegada.

A pasta uploads do exemplo ASP.NET Core no disco: blog com quatro fotos, as pastas vazias products e team e quatro fotos na raiz, todas com nomes gerados
O que as pastas da galeria são no disco: pastas comuns em uploads/.

Teste

Execute o projeto de exemplo deste guia com dotnet run. Na galeria, abra blog e clique em New folder. Ou use curl. A primeira criação responde com a listagem de blog; a segunda, com um conflito:

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

curl -F "action=create-folder" -F "folder=blog" -F "name=2026" http://localhost:5302/rte/gallery
{"error":"A folder with that name already exists."}

curl -F "action=create-folder" -F "folder=" -F "name=.." http://localhost:5302/rte/gallery
{"error":"Use 1-64 letters, digits, spaces, '-', '_' or '.', not starting with '.'."}

curl "http://localhost:5302/rte/gallery?folder=..%2F.."
{"error":"Invalid folder."}

curl "http://localhost:5302/rte/gallery?folder=blog%2Fnope"
{"error":"Folder not found."}

Os códigos de status são 200, 409, 400, 400 e 404. As imagens da primeira resposta foram omitidas aqui; a caixa de diálogo as usa para desenhar a pasta.

O mesmo guia para outras plataformas

JavaScript · PHP · MVC 5 · Web Forms

Leia a seguir