PHP のギャラリーフォルダー:安全に閲覧・作成する

サイトの画像が数百枚になると、1 つのフラットなフォルダーでは使いにくくなります。ギャラリーダイアログでは、サブフォルダーを閲覧したり、上の階層に戻ったり、「New folder」ボタンで新しいフォルダーを作成したりできます。このページでは PHP 側について説明します。ダイアログの仮想フォルダー名を uploads/ 以下のディレクトリに対応付ける方法と、リクエストで指定されたフォルダー名がディスク上のほかの場所に届かないようにする方法です。
ディスク上のパスではなく仮想フォルダー
ダイアログが送信するのは常に仮想パスです。ルートは ""、ほかに blog や products/2026 などです。Windows でも区切りには必ず / が使われ、サーバーがこれらをそのままパスとして使うことはありません。各部分は有効なフォルダー名でなければなりません。
// 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;
}最後の 2 つのルールは Windows のためのものです。Windows は abc. を知らないうちに abc に変え、CON や NUL などのデバイス名を予約しています。どのシステムでもこれらの名前を拒否しておけば、フォルダーツリーを Windows のノート PC から Linux サーバーに移しても同じように動作します。サンプルでは、どちらの名前も 400 になります。
フォルダーを安全に解決する
どのルートも同じ 2 つの手順を経ます。まず、仮想パスを / で分割し、各部分を名前のルールでチェックします。これだけで、..、/etc、C:\Windows、a\b、空の部分、%2e%2e が拒否されます。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);
}このコードの形は、realpath() の 2 つの性質で決まっています。
- まだ存在しないパスに対しては
falseを返します。そのため、これから作成するフォルダーのチェックには使えません。サンプルでは、既存の親フォルダーを解決してチェックし、その後に名前のルールを通過済みの名前を付け加えます。 - シンボリックリンクと Windows のジャンクションをたどります。当社のテストでは、プロジェクト内の別のフォルダーを指す
escapeというジャンクションをuploads/に置きました。realpath()はリンク先を返し、範囲のチェックがそれを拒否して、?folder=escapeは 400 になりました。同じマシンで、PHP 8.2 のis_link()はこのジャンクションに対してfalseを返したので、is_link()だけでは検出できなかったはずです。
Windows では、products と Products は同じフォルダーなので、範囲のチェックでは大文字と小文字を区別せずにパスを比較します。
フォルダーの作成
「New folder」ボタンは、action=create-folder、親フォルダー、新しい名前を同じ /rte/gallery の URL に POST します。レスポンスは親フォルダーの一覧なので、新しいフォルダーはすぐに表示されます。
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() によってわかりやすい 409 を返します。2 つのリクエストが競合した場合は、2 つ目の mkdir() が失敗し、それにも応答します。Windows では、既存の products の隣に Products を作成しようとした場合も 409 になりました。ファイルシステムがこれらを同じ名前として扱うためです。
users のチェックは、ユーザーごとのフォルダーのオプションに関係するものです。共有モードでは、サンプルは uploads/users/ を非表示にし、その作成も拒否します。そのため、各ユーザーのフォルダーが共有ギャラリーに表示されることはありません。

uploads/ フォルダーの状態。フォルダーの作成に失敗した場合、現在のダイアログは、すでに使われている名前に対する 409 も含め、どのエラーでも「The folder could not be created right now.」と表示します。サーバーが示した正確な理由はレスポンスの本文に含まれているので、ブラウザーの開発者ツールで確認できます。
テストする
このガイドのサンプルプロジェクトをポート 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."}レスポンスは順に 200、409、400 です。次に「Insert Gallery」を開いて blog をクリックすると、新しい 2026 フォルダーがあります。「Up」でルートに戻ります。
ほかのプラットフォーム向けの同じガイド
JavaScript · ASP.NET Core · MVC 5 · Web Forms