Carpetas de la galería en Node.js: explorar y crear de forma segura

Cuando la galería tiene más de unas pocas docenas de imágenes, los usuarios quieren carpetas: una para el blog, otra para los productos. El cuadro de diálogo de RichTextEditor puede explorar carpetas y crear otras nuevas, pero cada nombre de carpeta que envía es una entrada del usuario que acaba en una ruta de archivo. Esta página muestra cómo el ejemplo de Express convierte esos nombres en rutas dentro de uploads/ y rechaza todo lo demás.
Carpetas virtuales, no rutas de disco
El cuadro de diálogo solo envía carpetas virtuales: "" para la raíz, blog, blog/2026. Siempre usan /, sea cual sea el sistema operativo del servidor. El servidor decide dónde están en el disco. En el ejemplo, blog/2026 es uploads/blog/2026, y en el modo por usuario es uploads/users/alice/blog/2026. El navegador nunca conoce ninguna de las dos rutas.
Al abrir una carpeta se envía GET /rte/gallery?folder=blog. El botón New folder pide un nombre y envía action=create-folder, la carpeta superior en folder y el nuevo nombre en name a la misma URL. Las cargas envían la carpeta abierta junto con el archivo.
Una regla para cada parte de la ruta
Un nombre de carpeta puede tener de 1 a 64 letras, dígitos, espacios, -, _ y ., y no puede empezar por punto. El ejemplo añade dos reglas de Windows: nada de punto o espacio al final, porque Windows los elimina y photos. se convertiría sin avisar en photos, y nada de nombres de dispositivo como CON o 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() divide la carpeta virtual por / y aplica esa regla a cada parte. Esa sola comprobación ya descarta .., ., las partes vacías (que es lo que producen /etc y a//b), las barras invertidas, : (así que nada de letras de unidad), % (así que nada de %2e%2e con doble codificación) y NUL. Después resuelve la ruta y confirma que sigue dentro de la raíz:
// 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));
}Una entrada no válida recibe un 400; un nombre válido que no existe recibe un 404. El último paso compara resultados de realpath() en lugar de cadenas, por los enlaces. Pusimos en uploads/ una junction de Windows llamada linked que apuntaba a una carpeta en otro lugar del disco. ?folder=linked y ?folder=linked/sub recibieron ambos un 404, y el listado de la raíz no la mostraba.
express.static es otra historia: sigue los enlaces. Antes de añadir una comprobación, /uploads/linked/lake.jpg servía el archivo de la otra carpeta con un 200. La API de la galería no puede crear enlaces, así que solo alguien con acceso al disco puede hacer esto. Aun así, el ejemplo ahora ejecuta isServableUpload() antes de express.static, con la misma prueba de realpath(). Después de ese cambio, la misma URL respondió 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);
}Crear una carpeta
La ruta lee los tres campos que analizó multer y llama a createFolder(). Un POST que no es multipart deja req.body como undefined en Express 5 (vimos lo mismo en Express 4.22), así que la ruta lee 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 || "");
}mkdir() sin recursive falla con EEXIST cuando la carpeta ya existe, y eso se convierte en un 409. Si todo va bien, la respuesta es el listado de la carpeta superior, así que el cuadro de diálogo muestra la carpeta nueva de inmediato, sin una segunda solicitud. En el modo compartido, el nombre users está reservado en la raíz. Ahí es donde están las carpetas por usuario.
Carpetas que empiezan por punto
El listado oculta todo lo que empieza por punto, y la regla de nombres se niega a abrirlo. Las imágenes las sirve express.static, que tiene su propia regla, y esta cambió entre versiones. Pusimos uploads/.secret/a.txt en el disco y lo solicitamos sin la opción dotfiles. Express 4.22 (serve-static 1.16) lo sirvió con un 200. Express 5.3 (serve-static 2.2) respondió 404. Ambos rechazaron el propio /.env. El ejemplo establece dotfiles: "ignore" de forma explícita, para que el comportamiento no dependa de la versión que ejecutes.
Pruébalo
Inicia el proyecto de ejemplo de esta guía con npm start y prueba algunos nombres:
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."}Los códigos de estado son 200, 409, 400 y 400. En el navegador, haz clic en Insert Gallery y luego en New folder, y la carpeta nueva aparece junto a las demás. Ábrela y Upload pondrá el archivo en esa carpeta.
La misma guía para otras plataformas
PHP · ASP.NET Core · MVC 5 · Web Forms