Dossiers de galerie en PHP : parcourir et créer en toute sécurité

La boîte de dialogue Insert Gallery dans le dossier blog de l’exemple PHP, qui affiche quatre images avec le chemin /blog

Dès qu’un site compte quelques centaines d’images, un seul dossier à plat devient difficile à utiliser. La boîte de dialogue de la galerie peut parcourir des sous-dossiers, remonter d’un niveau et créer un nouveau dossier avec son bouton New folder. Cette page présente le côté PHP : comment les noms de dossiers virtuels de la boîte de dialogue correspondent à des répertoires sous uploads/, et comment empêcher un nom de dossier issu de la requête d’atteindre un autre endroit du disque.

Des dossiers virtuels, pas des chemins sur le disque

La boîte de dialogue n’envoie jamais que des chemins virtuels : "" pour la racine, blog, products/2026. Ils utilisent toujours /, y compris sous Windows, et le serveur ne s’en sert jamais directement comme chemins. Chaque partie doit être un nom de dossier valide :

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;
}

Les deux dernières règles existent à cause de Windows. Windows transforme discrètement abc. en abc et réserve des noms de périphériques comme CON et NUL. Refuser ces noms sur tous les systèmes garantit qu’une arborescence de dossiers se comporte de la même façon une fois déplacée d’un ordinateur portable Windows vers un serveur Linux. Les deux noms reçoivent un 400 de l’exemple.

Résoudre un dossier en toute sécurité

Chaque route passe par les deux mêmes étapes. D’abord, le chemin virtuel est découpé sur / et chaque partie est vérifiée selon la règle de nommage. Cela suffit à refuser .., /etc, C:\Windows, a\b, les parties vides et %2e%2e : PHP a décodé la chaîne de requête une fois, le % est toujours là, et % n’est pas un caractère autorisé. Ensuite, le dossier est résolu sur le disque et vérifié de nouveau :

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);
}

Deux particularités de realpath() déterminent ce code :

  • Elle renvoie false pour un chemin qui n’existe pas encore. Vous ne pouvez donc pas l’utiliser pour vérifier un dossier que vous êtes sur le point de créer. L’exemple résout le parent existant, le vérifie, puis y ajoute un nom qui a déjà passé la règle de nommage.
  • Elle suit les liens symboliques et les jonctions Windows. Nous avons placé dans uploads/ une jonction nommée escape qui pointait vers un autre dossier du projet. realpath() a renvoyé la cible, la vérification de confinement l’a refusée, et ?folder=escape a reçu un 400. Sur cette même machine, le is_link() de PHP 8.2 a renvoyé false pour la jonction : is_link() seul ne l’aurait donc pas détectée.

Sous Windows, la vérification de confinement compare les chemins sans tenir compte de la casse, car products et Products y désignent le même dossier.

Créer un dossier

Le bouton New folder envoie action=create-folder, le dossier parent et le nouveau nom à la même URL /rte/gallery. La réponse est la liste du parent : le nouveau dossier apparaît donc immédiatement.

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() donne un 409 clair pour un nom déjà pris. Si deux requêtes arrivent en même temps, mkdir() échoue pour la seconde, et ce cas reçoit aussi une réponse. Sous Windows, créer Products à côté d’un products existant a aussi renvoyé 409, car le système de fichiers les traite comme un même nom.

La vérification de users relève de l’option des dossiers par utilisateur. En mode partagé, l’exemple garde uploads/users/ masqué et refuse de le créer : les dossiers personnels n’apparaissent donc jamais dans la galerie partagée.

Le dossier uploads sur le serveur : blog avec quatre images, products, team, users avec alice et bob, et quatre images à la racine, chacune stockée sous la forme nom-8hex.jpg
L’état du dossier uploads/ de l’exemple après la prise des captures de ces guides.

Quand la création d’un dossier échoue, la boîte de dialogue affiche pour l’instant « The folder could not be created right now. » pour toutes les erreurs, y compris un 409 pour un nom déjà pris. La raison exacte donnée par le serveur figure dans le corps de la réponse, que vous pouvez consulter avec les outils de développement du navigateur.

Tester

Avec le projet d’exemple de ce guide en cours d’exécution sur le port 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."}

Les réponses sont 200, 409 et 400. Ouvrez ensuite Insert Gallery, cliquez sur blog : le nouveau dossier 2026 s’y trouve. Up vous ramène à la racine.

Le même guide pour d’autres plateformes

JavaScript · ASP.NET Core · MVC 5 · Web Forms

À lire ensuite