Galerieordner in PHP: sicher durchsuchen und anlegen

Der Dialog „Insert Gallery“ im Ordner blog des PHP-Beispiels mit vier Bildern und dem Pfad /blog

Sobald eine Website einige hundert Bilder hat, ist ein einziger flacher Ordner kaum noch zu gebrauchen. Der Galerie-Dialog kann Unterordner durchsuchen, wieder nach oben wechseln und mit seiner Schaltfläche „New folder“ einen neuen Ordner anlegen. Diese Seite zeigt die PHP-Seite: wie die virtuellen Ordnernamen des Dialogs auf Verzeichnisse unter uploads/ abgebildet werden und wie Sie verhindern, dass ein Ordnername aus dem Request irgendwo anders auf der Festplatte landet.

Virtuelle Ordner statt Pfade auf der Festplatte

Der Dialog sendet immer nur virtuelle Pfade: "" für den Stammordner, blog, products/2026. Sie verwenden immer /, auch unter Windows, und der Server verwendet sie nie direkt als Pfade. Jeder Teil muss ein gültiger Ordnername sein:

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

Die letzten beiden Regeln gibt es wegen Windows. Windows macht aus abc. stillschweigend abc und reserviert Gerätenamen wie CON und NUL. Werden diese Namen auf jedem System abgelehnt, verhält sich ein Ordnerbaum gleich, nachdem Sie ihn von einem Windows-Laptop auf einen Linux-Server verschoben haben. Beide Namen erhalten vom Beispiel ein 400.

Einen Ordner sicher auflösen

Jede Route durchläuft dieselben zwei Schritte. Zuerst wird der virtuelle Pfad an / aufgeteilt und jeder Teil gegen die Namensregel geprüft. Das allein lehnt .., /etc, C:\Windows, a\b, leere Teile und %2e%2e ab: PHP hat den Query-String einmal dekodiert, das % ist noch vorhanden, und % ist kein erlaubtes Zeichen. Danach wird der Ordner auf der Festplatte aufgelöst und erneut geprüft:

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

Zwei Eigenschaften von realpath() bestimmen diesen Code:

  • Die Funktion gibt für einen Pfad, der noch nicht existiert, false zurück. Sie können damit also keinen Ordner prüfen, den Sie gerade anlegen wollen. Das Beispiel löst den vorhandenen übergeordneten Ordner auf, prüft ihn und hängt dann einen Namen an, der die Namensregel bereits bestanden hat.
  • Sie folgt Symlinks und Windows-Junctions. Wir haben in uploads/ eine Junction namens escape angelegt, die auf einen anderen Ordner des Projekts zeigte. realpath() gab das Ziel zurück, die Eingrenzungsprüfung lehnte es ab, und ?folder=escape erhielt ein 400. Auf demselben Rechner gab is_link() in PHP 8.2 für die Junction false zurück, is_link() allein hätte sie also nicht erkannt.

Unter Windows vergleicht die Eingrenzungsprüfung Pfade ohne Rücksicht auf Groß- und Kleinschreibung, weil products und Products dort derselbe Ordner sind.

Einen Ordner anlegen

Die Schaltfläche „New folder“ sendet action=create-folder, den übergeordneten Ordner und den neuen Namen per POST an dieselbe URL /rte/gallery. Die Antwort ist die Liste des übergeordneten Ordners, sodass der neue Ordner sofort erscheint:

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() liefert das eindeutige 409 für einen bereits vergebenen Namen. Wenn sich zwei Requests überschneiden, schlägt mkdir() beim zweiten fehl, und auch darauf wird geantwortet. Unter Windows ergab auch das Anlegen von Products neben einem vorhandenen products ein 409, weil das Dateisystem beide als denselben Namen behandelt.

Die Prüfung auf users gehört zur Option Ordner pro Benutzer. Im gemeinsamen Modus hält das Beispiel uploads/users/ verborgen und verweigert das Anlegen, sodass die eigenen Ordner der Benutzer nie in der gemeinsamen Galerie auftauchen.

Der Ordner uploads auf dem Server: blog mit vier Bildern, products, team, users mit alice und bob und vier Bilder im Stammordner, jeweils als name-8hex.jpg gespeichert
So sah der Ordner uploads/ des Beispiels aus, nachdem die Bilder für diese Anleitungen aufgenommen waren.

Wenn das Anlegen eines Ordners fehlschlägt, zeigt der Dialog derzeit bei jedem Fehler „The folder could not be created right now.“ an, auch bei einem 409 für einen bereits vergebenen Namen. Der genaue Grund des Servers steht im Body der Antwort, den Sie in den Entwicklertools des Browsers sehen können.

Testen

Während das Beispielprojekt zu dieser Anleitung auf Port 5201 läuft:

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."}

Die Antworten sind 200, 409 und 400. Öffnen Sie dann Insert Gallery und klicken Sie auf blog: Der neue Ordner 2026 ist da. Mit „Up“ gelangen Sie zurück zum Stammordner.

Dieselbe Anleitung für andere Plattformen

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

Weiterlesen