Upload seguro de imagens em Next.js

Diagrama de um upload no exemplo em Next.js: navegador, o route handler, readForm(), as verificações em lib/gallery.ts e a rota de uploads, com falhas retornadas como 400 ou 413

O Next.js protege as Server Actions de formas que os route handlers não recebem, e os uploads do RichTextEditor vão para route handlers. Esta página percorre o que o exemplo de Next.js adiciona por conta própria: uma verificação de Origin contra POSTs de outros sites, as verificações do arquivo antes de qualquer gravação e uma rota que serve os uploads sem transformar o seu domínio em um servidor de arquivos.

Route handlers não têm verificação de Origin

O guia de segurança de dados do Next.js diz que as Server Actions comparam o cabeçalho Origin com o Host (ou X-Forwarded-Host) e abortam a requisição se forem diferentes. Os route handlers não fazem nada disso. Antes de adicionarmos uma verificação, enviamos POST /rte/gallery com Origin: https://evil.example e o exemplo criou a pasta: 200. Se o seu login usa cookies, qualquer outro site poderia fazer o navegador de um usuário autenticado fazer isso. Agora o exemplo recusa:

TypeScript · lib/gallery.ts
// Next.js checks the Origin of Server Actions, not of route handlers. A browser sends Origin
// with these POSTs, so refuse one from another site. Requests without Origin (curl) pass.
export function checkOrigin(request: Request): void {
  const origin = request.headers.get("origin");
  if (!origin) return;
  const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
  let originHost: string | null = null;
  try {
    originHost = new URL(origin).host;
  } catch {}
  if (originHost !== host) throw new GalleryError(403, "Cross-site request refused.");
}

Os dois handlers de POST chamam checkOrigin(request) primeiro, antes de ler o corpo. Verificamos que o Chromium envia Origin: http://localhost:5102 nos uploads do próprio editor, então eles passam. Uma requisição sem nenhum Origin, como curl ou código servidor a servidor, também passa. Ela não é uma requisição de navegador vinda de outro site, e a sua verificação de login continua valendo para ela. Origin: null, que páginas em sandbox enviam, é recusado. Atrás de um proxy reverso que muda o nome do host, garanta que ele defina X-Forwarded-Host; caso contrário, a verificação também recusa as suas próprias páginas.

O que é verificado antes da gravação

O diagrama no topo mostra a ordem de um upload. Depois da verificação de Origin, readForm() limita o tamanho do corpo (veja limites de tamanho para upload de imagens em Next.js) e, em seguida, lib/gallery.ts verifica o arquivo:

TypeScript · lib/gallery.ts
// Allowed extensions, each with a check of the file's first bytes (its signature).
const SIGNATURES: Record<string, (bytes: Buffer) => boolean> = {
  ".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: Buffer, start: number, end: number) => bytes.subarray(start, end).toString("hex");
const ascii = (bytes: Buffer, start: number, end: number) => bytes.subarray(start, end).toString("latin1");

// True for "chair.jpg", false for ".chair.jpg", "chair.svg" or "chair".
export function isImageName(name: string): boolean {
  return !name.startsWith(".") && Object.hasOwn(SIGNATURES, extensionOf(name));
}

// 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: unknown): name is string {
  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 estar na lista de permitidas: .jpg, .jpeg, .png, .gif ou .webp. SVG fica de fora porque pode conter script.
  • Os primeiros bytes precisam corresponder à extensão. File.type vindo de formData() nunca é usado: ele é o que o navegador declarou.
  • A pasta precisa ser um caminho virtual válido cujo caminho real esteja dentro de uploads/, como em pastas da galeria em Node.js.
  • O nome armazenado é gerado, e writeFile roda com "wx", então nunca sobrescreve.

Uma verificação de assinatura prova como um arquivo começa, não que ele seja uma imagem. Um arquivo que começa com bytes de JPEG e continua com HTML passa. É por isso que a forma como os arquivos são servidos importa.

Servir uploads sem public/

Os uploads não podem ir para public/, porque o next start serve apenas os arquivos que estavam lá no momento do build. Em vez disso, eles são servidos por app/uploads/[...path]/route.ts, o que lhe dá um lugar para colocar regras que public/ não teria:

TypeScript · lib/gallery.ts
// /uploads/<parts> -> the image file on disk, or a 404 GalleryError.
export async function resolveUpload(parts: string[]): Promise<string> {
  const name = parts.at(-1) ?? "";
  if (!isImageName(name) || /[\\/\0]/.test(name)) {
    throw new GalleryError(404, "Not found.");
  }
  const dir = await resolveFolder(UPLOADS_DIR, parts.slice(0, -1).join("/")).catch(() => {
    throw new GalleryError(404, "Not found.");
  });
  const file = path.join(dir, name);
  const info = await lstat(file).catch(() => null);
  if (!info || !info.isFile()) {
    throw new GalleryError(404, "Not found.");
  }
  return file;
}

Apenas nomes de imagem são servidos, e apenas de uma pasta que passe nas mesmas verificações de caminho. Testamos stray.html, .hidden.png, .secret/a.png, ..%2f..%2fpackage.png, ..%5cpackage.png e a%00.png: todos receberam 404. O Next.js decodifica os segmentos de [...path] antes que a rota os veja, então %2f chega como uma / real dentro de um segmento, e a verificação de nome o recusa. Uma junction do Windows colocada em uploads/ também foi recusada, porque a verificação compara caminhos reais. Toda imagem sai com X-Content-Type-Options: nosniff, então o navegador mantém o tipo image/* e não executa um arquivo como HTML.

Os três handlers exportam runtime = "nodejs". Eles precisam de node:fs, e a documentação do runtime Edge do Next.js é clara: lá você não pode ler nem gravar no sistema de arquivos.

O que o exemplo deixa para você

  • Quem pode enviar. O exemplo não tem login. lib/root-for.ts é onde entraria uma raiz por usuário, e a galeria compartilhada já mantém uploads/users/ fora de alcance para isso.
  • Quem pode ver uma imagem. /uploads/... é público. Adicione a sua verificação de sessão à rota de uploads se as imagens forem privadas.
  • Cotas, e a decodificação das imagens com uma biblioteca, se você precisar saber que são imagens completas.

Teste

Inicie o projeto de exemplo deste guia com npm run build e npm start e, depois:

Shell
curl -H "Origin: https://evil.example" -F action=create-folder -F folder= -F name=x http://localhost:5102/rte/gallery
{"error":"Cross-site request refused."}

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

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

Os status são 403, 400 e 400. Na caixa de diálogo da galeria, um arquivo recusado mostra "Upload failed for <name>.": a caixa de diálogo não exibe a mensagem do servidor.

O mesmo guia para outras plataformas

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

Leia a seguir