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

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:
// 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:
// 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
// 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.")retornouC:\app\uploads\photos. Uma pasta criada comophotos.seria listada comophotos, e os dois nomes apontariam para a mesma pasta. - Nomes de dispositivo.
con,nul,com1e similares, com ou sem extensão, são recusados.name=conrecebe 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:
// 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")retornaC:\Windows, ePath.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 paraPath.Combinesem 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.

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:
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