Node.js 中的图片库文件夹:安全地浏览和创建

图片库中的图片一旦超过几十张,用户就会想要文件夹:一个给博客,一个给产品。RichTextEditor 的对话框可以浏览文件夹和新建文件夹,但它发送的每个文件夹名称都是用户输入,最终会成为文件路径的一部分。本页介绍 Express 示例如何把这些名称转换为 uploads/ 下的路径,并拒绝其他所有输入。
虚拟文件夹,而不是磁盘路径
对话框只会发送虚拟文件夹:根目录为 "",以及 blog、blog/2026 等。无论服务器使用什么操作系统,它们始终使用 /。它们在磁盘上的位置由服务器决定。在示例中,blog/2026 对应 uploads/blog/2026,在每个用户模式下则对应 uploads/users/alice/blog/2026。浏览器永远不会知道这两个路径。
打开文件夹时发送 GET /rte/gallery?folder=blog。New folder 按钮会要求输入名称,然后向同一 URL POST action=create-folder、父文件夹 folder 和新名称 name。上传时会把当前打开的文件夹与文件一起 POST。
路径的每一部分都遵循同一条规则
文件夹名称可以由 1 到 64 个字母、数字、空格、-、_ 和 . 组成,并且不能以点开头。示例还增加了两条 Windows 规则:不能以点或空格结尾,因为 Windows 会丢弃它们,photos. 会悄悄变成 photos;也不能使用 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() 的结果而不是字符串,这是因为链接的存在。我们在 uploads/ 中放了一个名为 linked 的 Windows 目录联接(junction),指向磁盘上别处的一个文件夹。?folder=linked 和 ?folder=linked/sub 都得到 404,根目录列表中也没有显示它。
express.static 则不同:它会跟随链接。在添加检查之前,/uploads/linked/lake.jpg 以 200 提供了另一个文件夹中的文件。图片库 API 无法创建链接,因此只有能访问磁盘的人才能做到这一点。尽管如此,示例现在会在 express.static 之前运行 isServableUpload(),使用同样的 realpath() 检测。修改之后,同一个 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 解析出的三个字段,并调用 createFolder()。在 Express 5 中,非 multipart 的 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。成功时返回父文件夹的列表,因此对话框无需再次请求就能立即显示新文件夹。在共享模式下,根目录中的名称 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