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

Express のサンプルの blog フォルダー内を表示している「Insert Gallery」ダイアログ。パス /blog と 4 つの画像が表示されている

ギャラリーの画像が数十枚を超えると、ブログ用、製品用といったフォルダーが欲しくなります。RichTextEditor のダイアログではフォルダーを閲覧したり新しく作成したりできますが、ダイアログが送信するフォルダー名はすべてユーザーの入力であり、最終的にはファイルパスの一部になります。このページでは、Express のサンプルがそうした名前を uploads/ 以下のパスに変換し、それ以外をすべて拒否する方法を紹介します。

ディスク上のパスではなく仮想フォルダー

ダイアログが送信するのは常に仮想フォルダーです。ルートは ""、ほかに blog や blog/2026 などです。サーバーの OS にかかわらず、区切りには必ず / が使われます。ディスク上のどこに置くかはサーバーが決めます。サンプルでは、blog/2026 は uploads/blog/2026 になり、ユーザーごとのモードでは uploads/users/alice/blog/2026 になります。ブラウザーがどちらのパスを知ることもありません。

フォルダーを開くと GET /rte/gallery?folder=blog が送信されます。New folder ボタンは名前を尋ね、action=create-folder、親フォルダーの folder、新しい name を同じ URL に POST します。アップロードでは、開いているフォルダーがファイルと一緒に POST されます。

パスのすべての部分に同じルール

フォルダー名には、英字、数字、スペース、-、_、. を 1~64 文字使え、ドットで始めることはできません。サンプルでは Windows 向けのルールを 2 つ追加しています。1 つは、末尾にドットやスペースを置かないことです。Windows はこれらを削除するため、photos. が知らないうちに photos になってしまいます。もう 1 つは、CON や NUL などのデバイス名を使わないことです。

JavaScript · gallery.mjs
// Allowed extensions, each with a check of the file's first bytes (its signature).
const SIGNATURES = {
  ".jpg": (b) => hex(b, 0, 3) === "ffd8ff",
  ".jpeg": (b) => hex(b, 0, 3) === "ffd8ff",
  ".png": (b) => hex(b, 0, 8) === "89504e470d0a1a0a",
  ".gif": (b) => ascii(b, 0, 6) === "GIF87a" || ascii(b, 0, 6) === "GIF89a",
  ".webp": (b) => ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 12) === "WEBP",
};
const hex = (bytes, start, end) => bytes.subarray(start, end).toString("hex");
const ascii = (bytes, start, end) => bytes.subarray(start, end).toString("latin1");

// True for "chair.jpg", false for ".chair.jpg", "chair.svg" or "chair".
export function isImageName(name) {
  return !name.startsWith(".") && Object.hasOwn(SIGNATURES, extensionOf(name));
}

export class GalleryError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
  }
}

// A folder or file name: 1-64 letters, digits, space, - _ . and not starting with a dot.
// Also refused: a trailing dot or space (Windows drops them, so "a." would become "a")
// and Windows device names such as CON or NUL.
export function isValidName(name) {
  return (
    typeof name === "string" &&
    /^[A-Za-z0-9 _.-]{1,64}$/.test(name) &&
    !name.startsWith(".") &&
    !/[. ]$/.test(name) &&
    !/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(name)
  );
}

resolveFolder() は仮想フォルダーを / で分割し、各部分にこのルールを適用します。このチェックだけで、..、.、空の部分(/etc や a//b から生じます)、バックスラッシュ、:(つまりドライブ文字)、%(つまり二重エンコードされた %2e%2e)、NUL がすべて排除されます。そのうえでパスを解決し、まだルートの内側にあることを確認します。

JavaScript · gallery.mjs
// Turns a virtual folder into a disk path inside root. Every segment must be a valid
// name, which already rules out "..", ".", "", "\", ":", "%" and NUL. Throws 400 if the
// folder is invalid and 404 if it does not exist.
export async function resolveFolder(root, folder) {
  if (folder === undefined || folder === null) folder = "";
  if (typeof folder !== "string" || folder.length > 255) {
    throw new GalleryError(400, "Invalid folder.");
  }
  const segments = folder === "" ? [] : folder.split("/");
  if (!segments.every(isValidName)) {
    throw new GalleryError(400, "Invalid folder.");
  }

  await mkdir(root, { recursive: true });
  const dir = path.resolve(root, ...segments);
  let info;
  try {
    info = await lstat(dir);
  } catch {
    throw new GalleryError(404, "Folder not found.");
  }
  if (!info.isDirectory() || !(await isInside(root, dir))) {
    throw new GalleryError(404, "Folder not found.");
  }
  return dir;
}

// True when target (after following any links) is root or a folder below it.
async function isInside(root, target) {
  const realRoot = await realpath(root);
  const relative = path.relative(realRoot, await realpath(target));
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}

無効な入力には 400 を、有効だが存在しない名前には 404 を返します。最後の手順で文字列ではなく realpath() の結果を比較しているのは、リンクがあるためです。当社のテストでは、ディスク上の別の場所にあるフォルダーを指す linked という Windows のジャンクションを uploads/ に置きました。?folder=linked と ?folder=linked/sub はどちらも 404 になり、ルートの一覧にも表示されませんでした。

一方、express.static は事情が異なり、リンクをたどります。チェックを追加する前は、/uploads/linked/lake.jpg が別のフォルダーにあるファイルを 200 で配信していました。ギャラリーの API ではリンクを作れないため、これができるのはディスクにアクセスできる人だけです。それでも、サンプルでは現在、express.static の前で同じ realpath() のテストを行う isServableUpload() を実行しています。この変更の後は、同じ URL が 404 を返しました。

JavaScript · gallery.mjs
// For GET /uploads/<path>: an image name, and a real path inside UPLOADS_DIR. express.static
// alone follows a symbolic link or junction in uploads/ to the files it points at.
export async function isServableUpload(urlPath) {
  let relative;
  try {
    relative = decodeURIComponent(urlPath);
  } catch {
    return false;
  }
  if (!isImageName(path.posix.basename(relative)) || relative.includes("\\") || relative.includes("\0")) return false;
  return isInside(UPLOADS_DIR, path.join(UPLOADS_DIR, relative)).catch(() => false);
}

フォルダーの作成

ルートは multer が解析した 3 つのフィールドを読み取り、createFolder() を呼び出します。Express 5 では、マルチパートでない POST の場合、req.body は undefined のままです(Express 4.22 でも同じでした)。そのため、ルートでは req.body ?? {} を読み取ります。

JavaScript · server.mjs
// Every /rte request gets its storage root first, before multer reads the body.
app.use("/rte", (req, res, next) => {
  req.storageRoot = rootFor(req);
  next();
});

// GET /rte/gallery?folder=products  -> listing
app.get("/rte/gallery", async (req, res) => {
  res.json(await listFolder(req.storageRoot, req.query.folder ?? ""));
});

// POST /rte/gallery  action=create-folder, folder, name  -> listing of the parent folder
app.post("/rte/gallery", parseForm.none(), async (req, res) => {
  const { action, folder, name } = req.body ?? {};
  if (action !== "create-folder") throw new GalleryError(400, "Unknown action.");
  res.json(await createFolder(req.storageRoot, folder ?? "", name));
});

// POST /rte/upload  file, folder  -> { url }
app.post("/rte/upload", parseForm.single("file"), async (req, res) => {
  if (!req.file) throw new GalleryError(400, "No file was uploaded.");
  const url = await saveImage(req.storageRoot, req.body.folder ?? "", req.file.originalname, req.file.buffer);
  res.json({ url });
});
JavaScript · gallery.mjs
// Creates <parent>/<name> and returns the listing of the parent.
export async function createFolder(root, parent, name) {
  if (!isValidName(name)) {
    throw new GalleryError(400, "Folder names use 1-64 letters, digits, spaces, - _ and . and cannot start or end with a dot.");
  }
  if (isUsersFolder(root, parent)) throw new GalleryError(404, "Folder not found.");
  if (!parent && isUsersFolder(root, name)) throw new GalleryError(400, "The folder name users is reserved.");
  const dir = await resolveFolder(root, parent);
  try {
    await mkdir(path.join(dir, name));
  } catch (error) {
    if (error.code === "EEXIST") throw new GalleryError(409, "A folder with that name already exists.");
    throw error;
  }
  return listFolder(root, parent || "");
}

recursive を指定しない mkdir() は、フォルダーがすでにあると EEXIST で失敗し、これが 409 になります。成功したときのレスポンスは親フォルダーの一覧なので、ダイアログは 2 回目のリクエストなしで新しいフォルダーをすぐに表示できます。共有モードでは、ルートの users という名前は予約されています。そこがユーザーごとのフォルダーの置き場所です。

ドットで始まるフォルダー

一覧ではドットで始まるものはすべて非表示になり、名前のルールによってそうしたフォルダーを開くこともできません。画像は express.static が配信しますが、こちらには独自のルールがあり、バージョンによって変わっています。ディスクに uploads/.secret/a.txt を置き、dotfiles オプションを指定せずにリクエストしたところ、Express 4.22(serve-static 1.16)は 200 で配信し、Express 5.3(serve-static 2.2)は 404 を返しました。/.env そのものは、どちらも拒否しました。サンプルでは dotfiles: "ignore" を明示的に設定しているため、実行するバージョンによって動作が変わることはありません。

テストする

このガイドのサンプルプロジェクトを npm start で起動し、いくつかの名前を試します。

Shell
curl -F action=create-folder -F folder=blog -F name=2026 http://localhost:5101/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:5101/rte/gallery
{"error":"A folder with that name already exists."}

curl -F action=create-folder -F folder= -F name=.hidden http://localhost:5101/rte/gallery
{"error":"Folder names use 1-64 letters, digits, spaces, - _ and . and cannot start or end with a dot."}

curl "http://localhost:5101/rte/gallery?folder=a/../../x"
{"error":"Invalid folder."}

ステータスコードは順に 200、409、400、400 です。ブラウザーで Insert Gallery、続いて New folder をクリックすると、新しいフォルダーがほかのフォルダーと並んで表示されます。そのフォルダーを開いて Upload を使うと、ファイルはそのフォルダーに保存されます。

ほかのプラットフォーム向けの同じガイド

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

次に読む