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

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

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:

PHP · rte/files-lib.php
// 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:

PHP · rte/files-lib.php
// 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 false para 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 chamada escape que apontava para outra pasta do projeto. realpath() retornou o destino, a verificação de contenção o recusou, e ?folder=escape recebeu 400. Nessa mesma máquina, o is_link() do PHP 8.2 retornou false para a junction, então is_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:

PHP · rte/gallery.php
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 shows

file_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.

A pasta uploads no servidor: blog com quatro imagens, products, team, users com alice e bob, e quatro imagens na raiz, cada uma armazenada como nome-8hex.jpg
Como estava a pasta 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:

Shell
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

Leia a seguir