Pastas da galeria em PHP: navegar e criar com segurança

Quando um site passa de algumas centenas de imagens, uma única pasta plana fica difícil de usar. A caixa de diálogo da galeria pode navegar por subpastas, voltar um nível e criar uma pasta nova com o botão New folder. Esta página mostra o lado do PHP: como os nomes de pasta virtuais da caixa de diálogo correspondem a diretórios sob uploads/ e como impedir que um nome de pasta vindo da requisição alcance qualquer outro lugar do disco.
Pastas virtuais, não caminhos em disco
A caixa de diálogo só envia caminhos virtuais: "" para a raiz, blog, products/2026. Eles sempre usam /, inclusive no Windows, e o servidor nunca os usa diretamente como caminhos. Cada parte precisa ser um nome de pasta válido:
// Folder name rules: 1-64 chars, letters/digits/space/-/_/., not starting with ".".
// Also refused, because Windows silently rewrites or reserves them:
// a trailing dot or space, and device names like CON or NUL.
function rte_is_valid_name(string $name): bool
{
return preg_match('/\A[A-Za-z0-9 _\-][A-Za-z0-9 _.\-]{0,63}\z/', $name) === 1
&& preg_match('/[. ]\z/', $name) !== 1
&& preg_match('/\A(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(\..*)?\z/i', $name) !== 1;
}As duas últimas regras existem por causa do Windows. O Windows transforma silenciosamente abc. em abc e reserva nomes de dispositivo como CON e NUL. Recusar esses nomes em todos os sistemas faz com que uma árvore de pastas se comporte da mesma forma depois que você a move de um notebook com Windows para um servidor Linux. Os dois nomes recebem 400 do exemplo.
Resolver uma pasta com segurança
Toda rota passa pelos mesmos dois passos. Primeiro, o caminho virtual é dividido em / e cada parte é verificada pela regra de nomes. Só isso já recusa .., /etc, C:\Windows, a\b, partes vazias e %2e%2e: o PHP decodificou a query string uma vez, o % continua lá, e % não é um caractere permitido. Depois, a pasta é resolvida no disco e verificada de novo:
// A virtual folder ("", "products", "products/2026") must be a plain list of valid names.
// That alone refuses "..", "/x", "C:\x", "a\b", "a//b", NUL and "%2e%2e" ("%" is not allowed).
function rte_parse_folder(mixed $folder): array
{
if ($folder === null || $folder === '') {
return [];
}
if (!is_string($folder) || strlen($folder) > 512) { // ?folder[]=x arrives as an array
throw new RteHttpError(400, 'Invalid folder.');
}
$segments = explode('/', $folder);
foreach ($segments as $segment) {
if (!rte_is_valid_name($segment)) {
throw new RteHttpError(400, 'Invalid folder.');
}
}
return $segments;
}
// Resolves a virtual folder to an existing directory inside the root.
function rte_resolve_folder(mixed $folder): array
{
$segments = rte_parse_folder($folder);
if (rte_is_reserved($segments)) {
throw new RteHttpError(404, 'Folder not found.');
}
$root = rte_root();
$path = $root['dir'];
foreach ($segments as $segment) {
$path .= DIRECTORY_SEPARATOR . $segment;
}
// realpath() is false for a path that does not exist, and it follows symlinks and
// Windows junctions, so the check below sees where the folder really is.
$real = realpath($path);
if ($real === false || !is_dir($real)) {
throw new RteHttpError(404, 'Folder not found.');
}
if (!rte_is_inside($real, $root['dir'])) {
throw new RteHttpError(400, 'Invalid folder.');
}
return ['virtual' => implode('/', $segments), 'segments' => $segments, 'dir' => $real, 'root' => $root];
}
function rte_is_inside(string $path, string $root): bool
{
if (DIRECTORY_SEPARATOR === '\\') { // Windows paths are case-insensitive
$path = strtolower($path);
$root = strtolower($root);
}
return $path === $root || str_starts_with($path, rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
}Dois fatos sobre realpath() definem este código:
- Ele retorna
falsepara um caminho que ainda não existe. Por isso, você não pode usá-lo para verificar uma pasta que está prestes a criar. O exemplo resolve a pasta pai, que já existe, a verifica e então acrescenta um nome que já passou pela regra de nomes. - Ele segue links simbólicos e junctions do Windows. Colocamos dentro de
uploads/uma junction chamadaescapeque apontava para outra pasta do projeto.realpath()retornou o destino, a verificação de contenção o recusou, e?folder=escaperecebeu 400. Nessa mesma máquina, ois_link()do PHP 8.2 retornoufalsepara a junction, entãois_link()sozinho não a teria detectado.
No Windows, a verificação de contenção compara caminhos sem diferenciar maiúsculas e minúsculas, porque lá products e Products são a mesma pasta.
Criar uma pasta
O botão New folder envia por POST action=create-folder, a pasta pai e o novo nome para a mesma URL /rte/gallery. A resposta é a listagem da pasta pai, então a nova pasta aparece na hora:
if (($_POST['action'] ?? '') !== 'create-folder') {
throw new RteHttpError(400, 'Unknown action.');
}
$name = $_POST['name'] ?? '';
if (!is_string($name) || !rte_is_valid_name($name)) {
throw new RteHttpError(400, 'Folder names use 1-64 letters, digits, spaces, "-", "_" or ".", '
. 'cannot start with "." or end with "." or a space, and cannot be a device name such as CON or NUL.');
}
$parent = rte_resolve_folder($_POST['folder'] ?? '');
if (rte_is_reserved([...$parent['segments'], $name])) {
throw new RteHttpError(400, '"users" is reserved for per-user folders.');
}
$path = $parent['dir'] . DIRECTORY_SEPARATOR . $name;
// file_exists() first for a clear 409; mkdir() failing covers two requests racing.
if (file_exists($path) || !@mkdir($path, 0755)) {
if (file_exists($path)) {
throw new RteHttpError(409, 'A folder or file with that name already exists.');
}
throw new RteHttpError(500, 'The folder could not be created.');
}
return rte_listing($parent); // the parent's listing, so the new folder showsfile_exists() dá o 409 claro para um nome já em uso. Se duas requisições concorrerem, mkdir() falha na segunda, e isso também recebe resposta. No Windows, criar Products ao lado de uma products existente também retornou 409, porque o sistema de arquivos os trata como o mesmo nome.
A verificação de users pertence à opção de pastas por usuário. No modo compartilhado, o exemplo mantém uploads/users/ oculta e se recusa a criá-la, para que as pastas pessoais nunca apareçam na galeria compartilhada.

uploads/ do exemplo depois que as imagens destes guias foram capturadas.Quando a criação de uma pasta falha, a caixa de diálogo atualmente mostra "The folder could not be created right now." para todos os erros, inclusive um 409 de nome já em uso. O motivo exato dado pelo servidor está no corpo da resposta, que você pode ver nas ferramentas de desenvolvedor do navegador.
Teste
Com o projeto de exemplo deste guia rodando na porta 5201:
curl -F action=create-folder -F folder=blog -F name=2026 http://localhost:5201/rte/gallery
{"currentFolder":"blog","currentFolderDisplay":"/blog","parentFolder":"","folders":[{"folder":"blog/2026","name":"2026"}],...}
curl -F action=create-folder -F folder=blog -F name=2026 http://localhost:5201/rte/gallery
{"error":"A folder or file with that name already exists."}
curl "http://localhost:5201/rte/gallery?folder=../.."
{"error":"Invalid folder."}As respostas são 200, 409 e 400. Depois abra Insert Gallery, clique em blog, e a nova pasta 2026 estará lá. Up leva você de volta à raiz.
O mesmo guia para outras plataformas
JavaScript · ASP.NET Core · MVC 5 · Web Forms