Upload seguro de imagens em Node.js

Diagrama de um upload no exemplo em Express: navegador, armazenamento em memória do multer, as verificações em gallery.mjs, o arquivo em disco e o express.static, com falhas retornadas como 400 ou 413

Um endpoint de upload de imagens é uma forma de qualquer pessoa colocar um arquivo no seu servidor e depois fazer o seu próprio domínio servi-lo de volta. Esta página mostra o que o exemplo em Express verifica antes de gravar um arquivo, como ele serve os arquivos depois e o que as verificações não conseguem provar, com as requisições que usamos para testar cada parte.

O que o servidor verifica antes de gravar

O diagrama no topo mostra a ordem. O multer lê o formulário para a memória (os limites dele estão em limites de tamanho para upload de imagens em Node.js). Depois, saveImage() executa três verificações, e só então algo toca o disco:

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)
  );
}
  1. A extensão precisa ser .jpg, .jpeg, .png, .gif ou .webp. É uma lista de permissões, então .svg, .html e .php são recusados sem que o servidor precise conhecê-los. O SVG fica de fora de propósito: ele pode conter script.
  2. Os primeiros bytes precisam corresponder a essa extensão: FF D8 FF para JPEG, a assinatura PNG de 8 bytes, GIF87a ou GIF89a, e RIFF…WEBP. Um script renomeado para holiday.jpg falha aqui. O req.file.mimetype do multer não é usado: ele é o Content-Type que o navegador enviou, e o navegador pode enviar qualquer coisa.
  3. A pasta precisa ser um caminho virtual válido dentro da raiz de armazenamento (veja pastas da galeria em Node.js).
JavaScript · gallery.mjs
// Checks the name and the bytes, stores the file under a new random name, returns its URL.
export async function saveImage(root, folder, originalName, bytes) {
  const ext = extensionOf(originalName);
  const matches = Object.hasOwn(SIGNATURES, ext) && SIGNATURES[ext];
  if (!matches) {
    throw new GalleryError(400, "Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.");
  }
  if (bytes.length > MAX_BYTES) {
    throw new GalleryError(413, "The image is larger than 4,000,000 bytes.");
  }
  if (!matches(bytes)) {
    throw new GalleryError(400, "The file content is not a valid " + ext.slice(1).toUpperCase() + " image.");
  }

  if (isUsersFolder(root, folder)) throw new GalleryError(404, "Folder not found.");
  const dir = await resolveFolder(root, folder);
  const storedName = baseNameOf(originalName) + "-" + randomBytes(4).toString("hex") + ext;
  const file = path.join(dir, storedName);
  await writeFile(file, bytes, { flag: "wx" }); // "wx" fails instead of overwriting
  return publicUrl(file);
}

O nome original do arquivo nunca vira o nome armazenado. Ele é reduzido a a-z, 0-9 e -, cortado em 40 caracteres e seguido de oito caracteres hexadecimais aleatórios. A extensão original vem da lista de permissões, em minúsculas. Gravar com a flag "wx" significa que um arquivo existente nunca é substituído.

O que as verificações não conseguem provar

Uma verificação de assinatura diz como um arquivo começa, não o que vem depois. Enviamos um arquivo chamado polyglot.jpg que começa com os bytes JPEG FF D8 FF E0 e continua com <html><script>alert(1)</script>. Ele foi aceito. Isso é esperado: provar que um arquivo é uma imagem válida por inteiro exige decodificá-lo com uma biblioteca de imagens como o sharp, e recodificá-lo se você quiser remover qualquer coisa escondida nele. O exemplo não faz isso. O que torna um arquivo assim inofensivo é a forma como ele é servido.

Como os arquivos são servidos

JavaScript · server.mjs
// Uploaded images. Only image files inside uploads/ are served (isServableUpload), so a
// stray .html or .svg, or a link to another folder, is never served from your site.
// dotfiles "ignore" answers 404 for .files and anything inside a .folder (Express 5's
// default, written out because Express 4 served files inside .folders).
// nosniff stops the browser from guessing a content type other than the image's own.
app.use(
  "/uploads",
  async (req, res, next) => ((await isServableUpload(req.path)) ? next() : res.status(404).end()),
  express.static(UPLOADS_DIR, {
    dotfiles: "ignore",
    index: false,
    setHeaders: (res) => res.setHeader("X-Content-Type-Options", "nosniff"),
  }),
);

// The editor, the shared browser file and the demo page.
if (EDITOR_DIR) app.use("/richtexteditor", express.static(EDITOR_DIR));
app.get("/rte-files.js", (req, res) => res.sendFile(RTE_FILES_JS));
app.use(express.static(path.join(import.meta.dirname, "public")));
  • isServableUpload() deixa passar apenas nomes de arquivo de imagem cujo caminho real esteja dentro de uploads/. Colocamos manualmente um stray.html em uploads/. Ele recebeu 404, assim como um link para uma pasta externa.
  • dotfiles: "ignore" cobre o que essa verificação não cobre: um arquivo como .secret/a.jpg dentro de uma pasta que começa com ponto. Ele recebeu 404. O Express 5 faz isso por padrão. O Express 4.22 serviu uploads/.secret/a.txt com um 200 no nosso teste, por isso o exemplo declara a opção explicitamente.
  • X-Content-Type-Options: nosniff diz ao navegador para manter o Content-Type que o servidor enviou. O poliglota acima voltou como Content-Type: image/jpeg com nosniff, então o navegador o trata como imagem e nunca executa o script dele.

O que o exemplo deixa para você

  • Quem pode enviar. Não há login. Por padrão, o servidor escuta em 127.0.0.1 (HOST muda isso), então nada fora da sua máquina consegue acessá-lo enquanto você testa. Pastas de imagens por usuário em Node.js mostra onde o seu login se encaixa.
  • CSRF. O Express não tem proteção embutida para as duas rotas POST. Se o seu login usa cookies, outro site pode fazer o navegador de um usuário autenticado enviar requisições a elas. Verifique o cabeçalho Origin, como faz o exemplo em Next.js, ou use um token CSRF.
  • Cotas. Nada impede que um usuário encha o disco, 4 MB por vez.

Teste

Inicie o projeto de exemplo deste guia com npm start e envie a ele os arquivos desta página:

Shell
printf '<?php echo 1; ?>' > fake.jpg
curl -F "file=@fake.jpg" http://localhost:5101/rte/upload
{"error":"The file content is not a valid JPG image."}

curl -F "file=@photo.jpg;filename=x.svg" http://localhost:5101/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}

curl -F "file=@photo.jpg" http://localhost:5101/rte/upload
{"url":"/uploads/photo-05a3e02b.jpg"}

curl -I http://localhost:5101/uploads/photo-05a3e02b.jpg
HTTP/1.1 200 OK
X-Content-Type-Options: nosniff
Content-Type: image/jpeg

As duas primeiras respostas são HTTP 400. Na caixa de diálogo da galeria, o mesmo arquivo falso mostra "Upload failed for fake.jpg." (o upload de fake.jpg falhou). A caixa de diálogo usa o próprio texto, não a mensagem do servidor.

O mesmo guia para outras plataformas

Next.js · PHP · ASP.NET Core · Razor Pages · MVC 5 · Web Forms

Leia a seguir