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

ギャラリーの画像が数十枚を超えると、ブログ用、製品用といったフォルダーが欲しくなります。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 などのデバイス名を使わないことです。
// 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 がすべて排除されます。そのうえでパスを解決し、まだルートの内側にあることを確認します。
// 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 を返しました。
// 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 ?? {} を読み取ります。
// 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 });
});// 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 で起動し、いくつかの名前を試します。
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