Upload seguro de imagens em Next.js
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:
// 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:
// 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.typevindo deformData()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
writeFileroda 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:
// /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émuploads/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:
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