Pastas da galeria em Node.js: navegar e criar com segurança

A caixa de diálogo Insert Gallery dentro da pasta blog do exemplo em Express, mostrando quatro imagens com o caminho /blog

Quando a galeria passa de algumas dezenas de imagens, as pessoas querem pastas: uma para o blog, outra para os produtos. A caixa de diálogo do RichTextEditor pode navegar por pastas e criar novas, mas todo nome de pasta que ela envia é entrada do usuário que acaba em um caminho de arquivo. Esta página mostra como o exemplo em Express transforma esses nomes em caminhos dentro de uploads/ e recusa todo o resto.

Pastas virtuais, não caminhos em disco

A caixa de diálogo só envia pastas virtuais: "" para a raiz, blog, blog/2026. Elas sempre usam /, seja qual for o sistema operacional do servidor. É o servidor que decide onde elas ficam no disco. No exemplo, blog/2026 é uploads/blog/2026 e, no modo por usuário, é uploads/users/alice/blog/2026. O navegador nunca fica sabendo de nenhum dos dois caminhos.

Abrir uma pasta envia GET /rte/gallery?folder=blog. O botão New folder pede um nome e envia via POST action=create-folder, a pasta pai folder e o novo name para a mesma URL. Os uploads enviam a pasta aberta junto com o arquivo.

Uma regra para cada parte do caminho

Um nome de pasta pode ter de 1 a 64 letras, dígitos, espaços, -, _ e ., e não pode começar com ponto. O exemplo acrescenta duas regras do Windows: nada de ponto ou espaço no final, porque o Windows os descarta e photos. viraria photos sem aviso, e nada de nomes de dispositivo como CON ou 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() divide a pasta virtual em / e aplica essa regra a cada parte. Essa única verificação já elimina .., ., partes vazias (que é o que /etc e a//b produzem), barras invertidas, : (portanto, nada de letras de unidade), % (portanto, nada de %2e%2e com codificação dupla) e NUL. Em seguida, ela resolve o caminho e confirma que ele ainda está dentro da raiz:

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));
}

Uma entrada inválida recebe 400; um nome válido que não existe recebe 404. A última etapa compara resultados de realpath() em vez de strings, por causa dos links. Colocamos em uploads/ uma junction do Windows chamada linked, apontando para uma pasta em outro lugar do disco. ?folder=linked e ?folder=linked/sub receberam 404, e a listagem da raiz não a mostrou.

Com o express.static é diferente: ele segue links. Antes de adicionarmos uma verificação, /uploads/linked/lake.jpg servia o arquivo da outra pasta com um 200. A API da galeria não consegue criar links, então só alguém com acesso ao disco consegue fazer isso. Mesmo assim, o exemplo agora executa isServableUpload() antes do express.static, com o mesmo teste de realpath(). Depois dessa mudança, a mesma URL respondeu 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);
}

Criar uma pasta

A rota lê os três campos que o multer processou e chama createFolder(). Um POST que não é multipart deixa req.body como undefined no Express 5 (vimos o mesmo no Express 4.22), então a rota lê 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 || "");
}

mkdir() sem recursive falha com EEXIST quando a pasta já existe, e isso vira um 409. Em caso de sucesso, a resposta é a listagem da pasta pai, então a caixa de diálogo mostra a nova pasta imediatamente, sem uma segunda requisição. No modo compartilhado, o nome users é reservado na raiz. É ali que ficam as pastas por usuário.

Pastas que começam com ponto

A listagem oculta tudo o que começa com ponto, e a regra de nomes se recusa a abrir essas pastas. As imagens são servidas pelo express.static, que tem sua própria regra, e ela mudou entre versões. Colocamos uploads/.secret/a.txt no disco e o requisitamos sem a opção dotfiles. O Express 4.22 (serve-static 1.16) o serviu com um 200. O Express 5.3 (serve-static 2.2) respondeu 404. Ambos recusaram o próprio /.env. O exemplo define dotfiles: "ignore" explicitamente, para que o comportamento não dependa da versão que você usa.

Teste

Inicie o projeto de exemplo deste guia com npm start e experimente alguns nomes:

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."}

Os códigos de status são 200, 409, 400 e 400. No navegador, clique em Insert Gallery e depois em New folder, e a nova pasta aparece entre as outras. Abra-a, e Upload coloca o arquivo nessa pasta.

O mesmo guia para outras plataformas

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

Leia a seguir