Upload seguro de imagens em Node.js
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:
// 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)
);
}- 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.
- Os primeiros bytes precisam corresponder a essa extensão:
FF D8 FFpara JPEG, a assinatura PNG de 8 bytes,GIF87aouGIF89a, eRIFF…WEBP. Um script renomeado paraholiday.jpgfalha aqui. Oreq.file.mimetypedo multer não é usado: ele é oContent-Typeque o navegador enviou, e o navegador pode enviar qualquer coisa. - A pasta precisa ser um caminho virtual válido dentro da raiz de armazenamento (veja pastas da galeria em Node.js).
// 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
// 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 deuploads/. Colocamos manualmente umstray.htmlemuploads/. 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.jpgdentro de uma pasta que começa com ponto. Ele recebeu 404. O Express 5 faz isso por padrão. O Express 4.22 serviuuploads/.secret/a.txtcom um 200 no nosso teste, por isso o exemplo declara a opção explicitamente.X-Content-Type-Options: nosniffdiz ao navegador para manter oContent-Typeque o servidor enviou. O poliglota acima voltou comoContent-Type: image/jpegcomnosniff, 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(HOSTmuda 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:
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/jpegAs 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