Blog

Processar arquivo enviado pelo usuário sem travar a API: fila, limite de recurso e arquivo malicioso

Um sistema de gestão para escritórios de contabilidade permitia que cada cliente importasse lançamentos por planilha: o usuário escolhia um CSV ou XLSX exportado do banco ou do ERP, clicava em importar e esperava a tela confirmar. Funcionou por dois anos. No quinto dia útil de um mês de fechamento, um escritório grande enviou uma planilha de 38 MB com 410 mil linhas, e o p95 de toda a API, não só da importação, subiu de 180 milissegundos para 14 segundos. O pod que recebeu o arquivo passou do limite de memória e foi encerrado pelo kernel, o usuário clicou de novo, o arquivo caiu em outro pod e a cena se repetiu três vezes em vinte minutos. Duas semanas depois, um teste de intrusão contratado pelo maior cliente mostrou que um XLSX de 220 KB, montado para descompactar em 3,8 GB, derrubava qualquer instância com uma única requisição. Nenhum dos dois casos era um bug de lógica: a importação estava correta, só estava no lugar errado. Este artigo mostra por que processar o arquivo dentro da requisição derruba a API inteira, como separar receber de processar com fila e resposta 202, como dar ao worker um teto de memória e de tempo que não depende de o arquivo se comportar, o que verificar antes de abrir um arquivo que pode ser hostil, como impedir que um cliente ocupe a fila de todos e como provar com testes que esses limites seguram.

2026-10-07 / Arquitetura / 17 min

01

Por que processar o arquivo dentro da requisição derruba a API inteira

A versão que quase todo sistema começa usando cabe em quinze linhas: o multer guarda o arquivo em memória, uma biblioteca de planilhas lê tudo e um laço grava cada linha. Com os arquivos de homologação, de algumas centenas de linhas, a resposta vem em menos de um segundo e ninguém tem motivo para desconfiar.

// Versão que trava a API: o arquivo é lido e importado dentro da requisição
import express from 'express';
import multer from 'multer';
import XLSX from 'xlsx';
import { gravarLancamento, validarLinha } from './lancamentos.js';

const app = express();
const upload = multer({ storage: multer.memoryStorage() }); // sem limite de tamanho

app.post('/importacoes', upload.single('arquivo'), async (req, res) => {
  // Descompacta o XLSX e monta a planilha inteira na memória,
  // no mesmo processo que atende todas as outras rotas da API
  const planilha = XLSX.read(req.file.buffer);
  const linhas = XLSX.utils.sheet_to_json(planilha.Sheets[planilha.SheetNames[0]]);

  for (const linha of linhas) {
    await gravarLancamento(validarLinha(linha)); // 410 mil INSERTs com a conexão HTTP aberta
  }
  res.json({ importadas: linhas.length });
});

O primeiro problema é de CPU. No Node.js, descompactar um XLSX e converter o XML em objetos é trabalho síncrono, executado na mesma thread que atende todas as rotas. Enquanto XLSX.read processa 38 MB, o event loop não roda mais nada: o login, a listagem de clientes e o health check daquele pod esperam na fila do sistema operacional. É por isso que o p95 da API inteira subiu, e não só o da importação. Em runtimes com pool de threads, como Java ou Go, o mecanismo é outro, mas o efeito é parecido: algumas importações pesadas ocupam threads, conexões com o banco e memória que o resto do serviço compartilha.

O segundo é de memória, e ele é sempre maior do que o tamanho do arquivo. Um XLSX é um zip de arquivos XML, e cada etapa da leitura cria uma nova representação do mesmo conteúdo. A tabela mostra o que foi medido no incidente, com a planilha de 38 MB.

EtapaMemória aproximadaPor que cresce
Arquivo recebido em buffer pelo memoryStorage38 MBO corpo inteiro fica no heap antes de qualquer validação
XML descompactado das planilhasCerca de 310 MBA taxa de compressão de XML repetitivo passa de 8 para 1
Estrutura da planilha montada pela bibliotecaCerca de 900 MBCada célula vira um objeto com tipo, valor e formatação
Array gerado por sheet_to_jsonCerca de 420 MBMais um objeto por linha, com uma string por coluna
Pico no processoMais de 1,6 GBTudo coexiste até o fim do handler; o limite do pod era 1,5 GiB

O terceiro é de tempo e de repetição. A requisição levava cerca de três minutos, e o balanceador cortava conexões ociosas em 60 segundos. O usuário via um erro, clicava de novo, e a segunda tentativa começava enquanto a primeira ainda gravava linhas em outro pod, sem nada que impedisse lançamentos duplicados. A falha parecia um travamento para quem estava na tela e era, na verdade, trabalho dobrado no servidor. Aumentar timeouts, memória ou réplicas só move o ponto de ruptura para o próximo arquivo maior. A correção é mudar a forma: a requisição aceita e guarda o arquivo, e o trabalho pesado acontece em outro lugar, com limites próprios.

02

Separar receber de processar: aceitar, guardar e responder 202

Na nova forma, a API faz apenas o que é barato e previsível: aplica limites de tamanho enquanto lê o corpo, grava em disco temporário em vez de memória, calcula o hash, guarda o arquivo em um prefixo de quarentena no bucket com uma chave gerada pelo servidor, registra a importação, enfileira um job e responde 202 com o endereço onde o status pode ser consultado. O custo dessa requisição é de entrada e saída, cresce de forma linear com o tamanho do arquivo e a memória fica constante, porque nada é montado no heap.

Antes: tudo dentro da requisição
  navegador --POST 38 MB--> API: lê, descompacta, valida e grava 410 mil linhas --> 200 depois de 3 min
                            (o mesmo processo atende todas as outras rotas)

Depois: receber e processar são etapas diferentes
  navegador --POST--> API: limite de tamanho, hash, objeto em quarentena --> 202 + Location
                       |
                       +--> fila (jobId = id da importação)
                              |
                              v
                       worker (concorrência 2 por instância)
                         1. baixa e confere o tamanho
                         2. inspeciona: tipo real, zip, texto
                         3. processo filho: heap de 384 MB, 5 min, leitura em streaming
                         4. grava em lotes, em uma transação
                              |
                              v
                       status: concluida | rejeitada | falhou
  navegador --GET /importacoes/:id (2 s, depois 10 s)--> status e contagem de linhas
CREATE TABLE importacoes (
  id          uuid PRIMARY KEY,
  tenant_id   uuid NOT NULL,
  sha256      text NOT NULL,
  chave       text NOT NULL,          -- chave do objeto no bucket, gerada pelo servidor
  extensao    text NOT NULL,          -- '.csv' ou '.xlsx', já validada na entrada
  tamanho     bigint NOT NULL,
  status      text NOT NULL,          -- na_fila | processando | concluida | rejeitada | falhou
  motivo      text,
  linhas_ok   integer,
  linhas_erro integer,
  criada_em   timestamptz NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, sha256)          -- o mesmo arquivo do mesmo cliente vira a mesma importação
);
import express from 'express';
import multer from 'multer';
import IORedis from 'ioredis';
import pg from 'pg';
import { Queue } from 'bullmq';
import { S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { createHash, randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { unlink } from 'node:fs/promises';
import path from 'node:path';
import { autenticar } from './autenticacao.js'; // preenche req.tenantId

const MiB = 1024 * 1024;
const EXTENSOES_ACEITAS = new Set(['.csv', '.xlsx']);
const BUCKET = process.env.IMPORTACOES_BUCKET;

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const s3 = new S3Client({});
const conexao = new IORedis(process.env.REDIS_URL, { maxRetriesPerRequest: null });
const fila = new Queue('importacoes', { connection: conexao });

// Disco temporário em vez de memória, e limites que o multer aplica enquanto lê o corpo
const upload = multer({
  dest: '/tmp/recebidos',
  limits: { fileSize: 50 * MiB, files: 1, fields: 4, parts: 5 },
});

async function sha256DoArquivo(caminho) {
  const hash = createHash('sha256');
  for await (const pedaco of createReadStream(caminho)) hash.update(pedaco);
  return hash.digest('hex');
}

const buscarExistente = (tenantId, sha256) =>
  pool.query('SELECT id, status FROM importacoes WHERE tenant_id = $1 AND sha256 = $2', [
    tenantId,
    sha256,
  ]);

async function receberImportacao(req, res) {
  const arquivo = req.file;
  if (!arquivo) return res.status(400).json({ erro: 'arquivo_ausente' });

  try {
    const extensao = path.extname(arquivo.originalname).toLowerCase();
    if (!EXTENSOES_ACEITAS.has(extensao)) {
      return res.status(415).json({ erro: 'tipo_nao_aceito' });
    }

    // Duplo clique, retentativa do navegador ou reenvio no dia seguinte: mesma importação
    const sha256 = await sha256DoArquivo(arquivo.path);
    const existente = await buscarExistente(req.tenantId, sha256);
    if (existente.rowCount) return res.status(200).json(existente.rows[0]);

    // A chave é do servidor; o nome original nunca vira caminho
    const id = randomUUID();
    const chave = `quarentena/${req.tenantId}/${id}${extensao}`;
    await new Upload({
      client: s3,
      params: { Bucket: BUCKET, Key: chave, Body: createReadStream(arquivo.path) },
    }).done();

    const inserida = await pool.query(
      `INSERT INTO importacoes (id, tenant_id, sha256, chave, extensao, tamanho, status)
       VALUES ($1, $2, $3, $4, $5, $6, 'na_fila')
       ON CONFLICT (tenant_id, sha256) DO NOTHING
       RETURNING id, status`,
      [id, req.tenantId, sha256, chave, extensao, arquivo.size],
    );
    if (!inserida.rowCount) {
      // Corrida entre dois envios do mesmo arquivo: o objeto duplicado expira pela
      // regra de ciclo de vida do prefixo de quarentena
      const { rows } = await buscarExistente(req.tenantId, sha256);
      return res.status(200).json(rows[0]);
    }

    // jobId igual ao id da importação: reenfileirar a mesma importação não cria job novo
    await fila.add(
      'importar',
      { importacaoId: id },
      { jobId: id, attempts: 3, backoff: { type: 'exponential', delay: 30_000 } },
    );
    res.status(202).location(`/importacoes/${id}`).json({ id, status: 'na_fila' });
  } finally {
    await unlink(arquivo.path).catch(() => {});
  }
}

const app = express();
app.post('/importacoes', autenticar, upload.single('arquivo'), receberImportacao);

app.get('/importacoes/:id', autenticar, async (req, res) => {
  if (!/^[0-9a-f-]{36}$/.test(req.params.id)) return res.status(404).end();
  const { rows } = await pool.query(
    `SELECT id, status, motivo, linhas_ok, linhas_erro
       FROM importacoes WHERE id = $1 AND tenant_id = $2`,
    [req.params.id, req.tenantId],
  );
  if (!rows.length) return res.status(404).end();
  res.json(rows[0]);
});

// Limites do multer viram respostas claras, não 500
app.use((erro, req, res, next) => {
  if (erro instanceof multer.MulterError) {
    const status = erro.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
    return res.status(status).json({ erro: erro.code });
  }
  next(erro);
});

app.listen(3000);
  • O limite de tamanho precisa existir em todas as camadas. O multer interrompe a leitura no primeiro byte acima de 50 MB e devolve 413, mas o proxy ou o balanceador deve ter um limite parecido, para que um corpo de 5 GB não chegue nem a ocupar um processo da aplicação.
  • A chave do objeto é gerada pelo servidor. O nome original serve apenas para exibição e nunca vira caminho de arquivo, o que elimina de uma vez sobrescrita de objetos e travessia de diretório com nomes como ../../config.
  • O hash torna o envio idempotente. Duplo clique, retentativa do navegador e reenvio do mesmo arquivo no dia seguinte devolvem a mesma importação, e a restrição única no banco resolve a corrida entre dois envios simultâneos.
  • A ordem é objeto, linha, job. Se o enfileiramento falhar depois do INSERT, a importação fica na_fila sem job; um processo de reconciliação reenfileira importações nesse estado há mais de cinco minutos, e o jobId igual ao id da importação torna o reenvio inofensivo.
  • A extensão é só a primeira triagem, e é barata. Ela não prova nada sobre o conteúdo, que é verificado no worker antes de qualquer parser abrir o arquivo.

O 202 muda o contrato com a interface, e essa é a parte que costuma gerar resistência. A tela passa a mostrar a importação como recebida e consulta o status no endereço do cabeçalho Location, com intervalo crescente: a cada 2 segundos no início e a cada 10 depois do primeiro minuto. O ganho é que a resposta deixa de depender do tamanho do arquivo. Um envio de 50 MB responde em segundos, o usuário pode fechar a aba, e o resultado continua disponível na lista de importações, com a contagem de linhas aceitas e recusadas.

03

O worker com teto: processo isolado, tempo e memória limitados

Mover o processamento para um worker resolve a latência da API, mas não resolve o arquivo hostil. Sem limites, a bomba de 220 KB derruba o worker em vez da API. Pior: como o job não foi concluído, a fila o entrega de novo a outra instância, que também morre, e um único arquivo derruba a frota inteira de workers, um de cada vez. Por isso o worker precisa de tetos que não dependem de o arquivo se comportar bem, e de uma regra clara sobre o que vale tentar de novo.

import { Worker, UnrecoverableError } from 'bullmq';
import IORedis from 'ioredis';
import pg from 'pg';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { execFile } from 'node:child_process';
import { createWriteStream } from 'node:fs';
import { rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { inspecionar } from './inspecionar.js';

const execFileAsync = promisify(execFile);
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const s3 = new S3Client({});
const BUCKET = process.env.IMPORTACOES_BUCKET;
const conexao = new IORedis(process.env.REDIS_URL, { maxRetriesPerRequest: null });
const SCRIPT_FILHO = fileURLToPath(new URL('./importar-csv.js', import.meta.url));

const marcar = (id, status, motivo = null) =>
  pool.query('UPDATE importacoes SET status = $2, motivo = $3 WHERE id = $1', [id, status, motivo]);

// Baixa para o disco conferindo o tamanho: o objeto não pode ser maior do que o registrado
async function baixar(chave, destino, tamanhoEsperado) {
  const { Body } = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: chave }));
  let recebidos = 0;
  const conferir = new Transform({
    transform(pedaco, _codificacao, pronto) {
      recebidos += pedaco.length;
      if (recebidos > tamanhoEsperado) return pronto(new UnrecoverableError('tamanho_divergente'));
      pronto(null, pedaco);
    },
  });
  await pipeline(Body, conferir, createWriteStream(destino));
}

// O parser roda em um processo filho com teto de heap e de tempo.
// Se o arquivo estourar a memória ou travar o parser, morre o filho, não o worker.
async function processarIsolado(caminho, importacaoId) {
  try {
    const { stdout } = await execFileAsync(
      process.execPath,
      ['--max-old-space-size=384', SCRIPT_FILHO, caminho, importacaoId],
      {
        timeout: 5 * 60_000,
        killSignal: 'SIGKILL',
        maxBuffer: 64 * 1024,
        env: { DATABASE_URL: process.env.DATABASE_URL },
      },
    );
    return JSON.parse(stdout);
  } catch (erro) {
    if (erro.killed) throw new UnrecoverableError('tempo_esgotado');
    // SIGABRT: o V8 abortou ao atingir o teto de heap. SIGKILL sem killed: o kernel matou por memória.
    if (erro.signal === 'SIGABRT' || erro.signal === 'SIGKILL') {
      throw new UnrecoverableError('memoria_esgotada');
    }
    if (erro.stderr?.includes('linhas_demais')) throw new UnrecoverableError('linhas_demais');
    throw erro; // banco fora do ar, rede: vale tentar de novo
  }
}

const worker = new Worker(
  'importacoes',
  async (job) => {
    const { rows } = await pool.query('SELECT * FROM importacoes WHERE id = $1', [
      job.data.importacaoId,
    ]);
    const importacao = rows[0];
    // Reentrega de um job já resolvido não reprocessa nada
    if (!importacao || ['concluida', 'rejeitada'].includes(importacao.status)) return;

    await marcar(importacao.id, 'processando');
    const caminho = path.join(tmpdir(), `${importacao.id}${importacao.extensao}`);
    try {
      await baixar(importacao.chave, caminho, Number(importacao.tamanho));

      const veredito = await inspecionar(caminho, importacao.extensao);
      if (!veredito.ok) {
        await marcar(importacao.id, 'rejeitada', veredito.motivo);
        throw new UnrecoverableError(veredito.motivo);
      }

      const { ok, erros } = await processarIsolado(caminho, importacao.id);
      await pool.query(
        "UPDATE importacoes SET status = 'concluida', linhas_ok = $2, linhas_erro = $3 WHERE id = $1",
        [importacao.id, ok, erros],
      );
    } finally {
      await rm(caminho, { force: true });
    }
  },
  { connection: conexao, concurrency: 2 },
);

// Falha definitiva: erro irrecuperável ou tentativas esgotadas
worker.on('failed', async (job, erro) => {
  if (!job) return;
  const definitiva =
    erro.name === 'UnrecoverableError' || job.attemptsMade >= (job.opts.attempts ?? 1);
  if (!definitiva) return;
  await pool.query(
    "UPDATE importacoes SET status = 'falhou', motivo = $2 WHERE id = $1 AND status = 'processando'",
    [job.data.importacaoId, erro.message],
  );
});

O processo filho lê o CSV em streaming, valida cada linha e grava em lotes de mil com unnest, tudo dentro de uma transação que começa apagando o que uma tentativa anterior tenha gravado. A memória fica proporcional ao lote, não ao arquivo, e uma retentativa nunca duplica lançamentos. A versão para XLSX troca o csv-parse pelo leitor em streaming do ExcelJS e mantém o resto igual.

// importar-csv.js: roda no processo filho, com heap e tempo limitados pelo worker
import { createReadStream } from 'node:fs';
import { parse } from 'csv-parse';
import pg from 'pg';

const [caminho, importacaoId] = process.argv.slice(2);
const MAX_LINHAS = 500_000;
const TAMANHO_LOTE = 1_000;

function validar(registro) {
  const data = registro.data ?? '';
  const dataValida =
    /^\d{4}-\d{2}-\d{2}$/.test(data) &&
    new Date(`${data}T00:00:00Z`).toISOString().slice(0, 10) === data;
  const valor = /^-?\d{1,12}(\.\d{1,2})?$/.test(registro.valor ?? '') ? registro.valor : null;
  const conta = (registro.conta ?? '').trim();
  if (!dataValida || valor === null || !conta || conta.length > 40) return null;
  return { data, conta, valor };
}

const cliente = new pg.Client({ connectionString: process.env.DATABASE_URL });
await cliente.connect();

let ok = 0;
let erros = 0;
let lote = [];

async function gravarLote() {
  if (!lote.length) return;
  await cliente.query(
    `INSERT INTO lancamentos (importacao_id, linha, data, conta, valor)
     SELECT $1::uuid, * FROM unnest($2::int[], $3::date[], $4::text[], $5::numeric[])`,
    [
      importacaoId,
      lote.map((l) => l.linha),
      lote.map((l) => l.data),
      lote.map((l) => l.conta),
      lote.map((l) => l.valor),
    ],
  );
  lote = [];
}

try {
  await cliente.query('BEGIN');
  // Uma retentativa recomeça do zero sem duplicar o que a tentativa anterior gravou
  await cliente.query('DELETE FROM lancamentos WHERE importacao_id = $1', [importacaoId]);

  const registros = createReadStream(caminho).pipe(
    parse({ columns: true, bom: true, skip_empty_lines: true, max_record_size: 64 * 1024 }),
  );
  let linha = 1; // a linha 1 é o cabeçalho
  for await (const registro of registros) {
    linha += 1;
    if (linha - 1 > MAX_LINHAS) throw new Error('linhas_demais');
    const valido = validar(registro);
    if (!valido) {
      erros += 1;
      continue;
    }
    lote.push({ linha, ...valido });
    ok += 1;
    if (lote.length >= TAMANHO_LOTE) await gravarLote();
  }
  await gravarLote();
  await cliente.query('COMMIT');
  process.stdout.write(JSON.stringify({ ok, erros }));
} catch (erro) {
  await cliente.query('ROLLBACK').catch(() => {});
  process.stderr.write(String(erro.message));
  process.exitCode = 1;
} finally {
  await cliente.end();
}
LimiteOnde é aplicadoQuando estouraTentar de novo?
Tamanho do arquivoProxy, multer na API e conferência no download413 na API ou rejeitada no workerNão: o mesmo arquivo tem o mesmo tamanho
Heap do parser--max-old-space-size no processo filhoO V8 aborta o filho e o job vira memoria_esgotadaNão: o resultado é determinístico
Memória total do contêinerLimite de memória do podO kernel mata o processo que mais consome, normalmente o filhoNão, e o limite precisa ser dimensionado
Tempo de processamentotimeout do execFile com SIGKILLtempo_esgotadoNão: o parser que travou com um arquivo trava de novo
Quantidade de linhasContador no processo filholinhas_demais, com mensagem ao usuárioNão: é regra de produto
Tamanho de um registromax_record_size do csv-parseErro de leitura no filhoNão: uma linha de 64 KB em um extrato é defeito ou ataque
Banco ou rede indisponívelErro comum no filho ou no workerO job volta para a fila com backoffSim: até 3 tentativas
  • Processo filho e não worker_threads, neste caso. Um worker thread com resourceLimits também limita o heap e é mais leve, e serve bem para parsers escritos só em JavaScript. O processo separado isola também a memória nativa e uma eventual falha de biblioteca nativa, e o SIGKILL no fim do prazo é garantido, sem depender de o código cooperar.
  • O teto de heap não é o teto de memória. --max-old-space-size limita o heap do V8, mas Buffers e alocações nativas ficam fora dele. O limite do contêiner é a última barreira e precisa ser calculado: concorrência vezes o teto do filho com margem para memória nativa, mais o consumo do próprio worker. Com concorrência 2 e heap de 384 MB, um limite de 1,5 GiB deixa folga.
  • Classificar a falha é o que impede a cascata. UnrecoverableError faz o BullMQ mover o job direto para falhos, sem as três tentativas. Repetir um arquivo que estourou memória só derruba mais dois filhos e atrasa a resposta ao usuário. Repetir uma queda do banco faz sentido, porque ela passa.
  • Para importações muito grandes, a transação longa vira um problema próprio: segura locks e infla o WAL. A alternativa é gravar em uma tabela de staging e promover para a tabela final em uma única operação no fim, mantendo a mesma garantia de tudo ou nada.

04

Arquivo malicioso: o que verificar antes de abrir

Um arquivo enviado pelo usuário é entrada controlada por quem envia, e o parser que vai abri-lo é um dos códigos mais complexos do sistema. A estratégia é verificar o que é barato antes de chamar o que é caro, em camadas, sabendo que nenhuma verificação isolada é suficiente. O módulo abaixo roda no worker depois do download e antes do processo filho.

// inspecionar.js: verificações baratas, feitas antes de qualquer parser abrir o arquivo
import { fileTypeFromFile } from 'file-type';
import { open } from 'node:fs/promises';
import yauzl from 'yauzl';

const MiB = 1024 * 1024;
const LIMITES_ZIP = {
  entradas: 2_000,
  totalDescompactado: 200 * MiB,
  razaoMaxima: 200, // calibrada com arquivos reais dos clientes, não com um chute
};

export function inspecionarZip(caminho) {
  return new Promise((resolve) => {
    yauzl.open(caminho, { lazyEntries: true, validateEntrySizes: true }, (erroAbertura, zip) => {
      if (erroAbertura) return resolve({ ok: false, motivo: 'zip_corrompido' });
      let entradas = 0;
      let total = 0;
      const recusar = (motivo) => {
        zip.close();
        resolve({ ok: false, motivo });
      };

      zip.on('entry', (entrada) => {
        entradas += 1;
        total += entrada.uncompressedSize;
        if (entradas > LIMITES_ZIP.entradas) return recusar('entradas_demais');
        if (total > LIMITES_ZIP.totalDescompactado) return recusar('descompactado_grande_demais');
        const razao = entrada.uncompressedSize / Math.max(entrada.compressedSize, 1);
        if (entrada.uncompressedSize > MiB && razao > LIMITES_ZIP.razaoMaxima) {
          return recusar('razao_de_compressao_suspeita');
        }
        if (entrada.fileName.startsWith('/') || entrada.fileName.split('/').includes('..')) {
          return recusar('caminho_invalido');
        }
        zip.readEntry();
      });
      zip.on('end', () => resolve({ ok: true }));
      zip.on('error', () => resolve({ ok: false, motivo: 'zip_corrompido' }));
      zip.readEntry();
    });
  });
}

async function inspecionarTexto(caminho) {
  // Um CSV não tem assinatura binária: se file-type reconhece algo, é outro tipo renomeado
  if (await fileTypeFromFile(caminho)) return { ok: false, motivo: 'csv_com_conteudo_binario' };
  const arquivo = await open(caminho);
  try {
    const { buffer, bytesRead } = await arquivo.read(Buffer.alloc(64 * 1024), 0, 64 * 1024, 0);
    const inicio = buffer.subarray(0, bytesRead);
    if (inicio.includes(0)) return { ok: false, motivo: 'csv_com_byte_nulo' };
    try {
      // stream: true tolera um caractere multibyte cortado no fim do trecho lido
      new TextDecoder('utf-8', { fatal: true }).decode(inicio, { stream: true });
    } catch {
      return { ok: false, motivo: 'csv_nao_e_utf8' };
    }
    return { ok: true };
  } finally {
    await arquivo.close();
  }
}

export async function inspecionar(caminho, extensao) {
  if (extensao === '.csv') return inspecionarTexto(caminho);
  if (extensao === '.xlsx') {
    const tipo = await fileTypeFromFile(caminho);
    if (tipo?.ext !== 'xlsx') return { ok: false, motivo: 'conteudo_nao_e_xlsx' };
    return inspecionarZip(caminho);
  }
  return { ok: false, motivo: 'tipo_nao_aceito' };
}
AmeaçaComo apareceDefesa
Tipo falsoExecutável renomeado para .csv, HTML enviado como .xlsxAssinatura pelos primeiros bytes; CSV sem assinatura binária, sem byte nulo e em UTF-8 válido
Bomba de descompressãoXLSX de 220 KB com 3,8 GB de XML repetitivoSoma dos tamanhos declarados, razão de compressão, quantidade de entradas e teto de heap no filho
Zip slipEntrada com nome ../../app/config.jsNunca extrair para o disco; se extrair, recusar caminhos absolutos e com ..
Entidades XMLDOCTYPE com entidade externa ou expansão em cascataParser que não resolve entidades externas nem expande DTD; recusar DOCTYPE em XLSX
Injeção de fórmulaCélula começando com =, +, - ou @ que vira fórmula quando alguém abre a exportaçãoNeutralizar com apóstrofo nas células de texto ao exportar, não ao importar
Malware conhecidoAnexo que depois é baixado por outros usuáriosClamAV em serviço separado, assinaturas atualizadas e resultado gravado no status
Imagem bombaPNG de 50 KB declarando 50.000 por 50.000 pixelsLimitar pixels antes de decodificar, como faz o limitInputPixels do sharp
  • Os tamanhos do diretório central de um zip são declarados por quem montou o arquivo e podem mentir. A inspeção barra o caso comum em milissegundos, sem descompactar nada; o arquivo forjado com tamanhos falsos passa por ela e é barrado pelo teto de heap do filho. É defesa em camadas, não uma verificação definitiva.
  • O arquivo fica em quarentena até ser aprovado. Só depois da inspeção e do processamento ele é copiado para o prefixo definitivo ou disponibilizado para outros usuários, e nunca é servido com o Content-Type informado pelo cliente: use Content-Disposition attachment e X-Content-Type-Options nosniff.
  • O antivírus também é um parser. Rode o clamd em um contêiner próprio, com StreamMaxLength, MaxScanSize e MaxFileSize configurados, e trate o tempo esgotado do antivírus como rejeição, não como aprovação.
  • A biblioteca também é superfície de ataque. A versão do pacote xlsx publicada no registro do npm parou na 0.18.5 e tem vulnerabilidades conhecidas de prototype pollution e de ReDoS; o código ingênuo do início estava exposto a elas sem ninguém saber. Fixe versões, acompanhe alertas de dependência e prefira bibliotecas com leitura em streaming.

05

Um cliente não pode ocupar a fila de todos

Com a API protegida e os workers limitados, o incidente seguinte foi de outro tipo. No fechamento do mês seguinte, um escritório enviou 60 planilhas em sequência. A fila era FIFO, com três workers de concorrência 2, e a planilha de 200 KB de outro cliente esperou 25 minutos atrás das 60. Nada caiu e nenhum limite estourou, mas para quem esperava o sistema estava fora do ar. Fila compartilhada sem regra de justiça transforma o maior cliente no gargalo de todos.

const EM_ANDAMENTO_POR_CLIENTE = 5;

// Roda antes do multer: recusa sem ler os 50 MB do corpo
async function limitarPorCliente(req, res, next) {
  const { rows } = await pool.query(
    `SELECT count(*)::int AS n FROM importacoes
      WHERE tenant_id = $1 AND status IN ('na_fila', 'processando')`,
    [req.tenantId],
  );
  if (rows[0].n >= EM_ANDAMENTO_POR_CLIENTE) {
    return res.status(429).set('Retry-After', '60').json({ erro: 'importacoes_em_andamento' });
  }
  next();
}

app.post('/importacoes', autenticar, limitarPorCliente, upload.single('arquivo'), receberImportacao);

O teto de importações em andamento por cliente é a defesa mais barata e roda antes do multer, então recusa sem ler o corpo. Ele é um limite suave: duas requisições simultâneas podem passar pela contagem ao mesmo tempo, e isso é aceitável, porque o objetivo é impedir sessenta, não garantir exatamente cinco. Ele não resolve tudo, e as outras estratégias se combinam com ele.

EstratégiaComo funcionaQuando usarCusto
Teto por clienteA API conta importações na_fila e processando do cliente e responde 429 com Retry-AfterSempre: é barato e protege contra o pior casoQuem envia lotes grandes precisa esperar ou enviar aos poucos
Filas por classe de tamanhoArquivos até 2 MB em uma fila, maiores em outra, cada uma com workers própriosQuando arquivos pequenos são maioria e precisam de resposta rápidaDuas filas para monitorar e dimensionar
Prioridade por carga recenteJob de cliente com muitas importações na última hora entra com prioridade menorQuando o volume por cliente varia muito ao longo do mêsPrioridade não é garantia, e jobs de baixa prioridade podem envelhecer
Fila no PostgreSQL com escolha por clienteO worker pega o job do cliente com menos itens em andamento, usando FOR UPDATE SKIP LOCKEDQuando a justiça precisa ser exataConsulta mais cara e mais código próprio

A métrica que denuncia esse problema não é o tamanho da fila, é a idade do job mais antigo, separada por classe de tamanho, e o tempo de espera p95 por cliente. Uma fila de 60 jobs pode estar saudável; um job de 200 KB esperando há 25 minutos nunca está. Acompanhe também as rejeições por motivo, porque um aumento súbito de razao_de_compressao_suspeita é sinal de ataque, e um aumento de linhas_demais é sinal de que a regra de produto ficou pequena para os clientes reais.

06

Como provar que os limites seguram

Um teste que importa um CSV de dez linhas e confere o resultado não prova nada sobre os incidentes. O que precisa ser provado é que o arquivo hostil é recusado antes de abrir, que o parser que estoura memória morre sozinho e não leva o worker, e que a API continua respondendo enquanto importações pesadas acontecem. Os arquivos hostis devem ser gerados no próprio teste, e não versionados: uma bomba de descompressão no repositório é um risco para qualquer ferramenta que o indexe.

import { test, expect } from 'vitest';
import { randomBytes } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import { finished } from 'node:stream/promises';
import yazl from 'yazl';
import { inspecionarZip } from '../src/inspecionar.js';

const MiB = 1024 * 1024;

function* blocos(total, gerar) {
  for (let enviado = 0; enviado < total; enviado += MiB) yield gerar();
}

// Os arquivos hostis são gerados no teste, não versionados no repositório
async function criarZip(nome, entradas) {
  const pasta = await mkdtemp(path.join(tmpdir(), 'inspecao-'));
  const destino = path.join(pasta, nome);
  const zip = new yazl.ZipFile();
  for (const { caminho, bytes, aleatorio } of entradas) {
    const gerar = aleatorio ? () => randomBytes(MiB) : () => Buffer.alloc(MiB);
    zip.addReadStream(Readable.from(blocos(bytes, gerar)), caminho);
  }
  zip.end();
  await finished(zip.outputStream.pipe(createWriteStream(destino)));
  return destino;
}

test('recusa bomba de descompressão pelo total declarado', async () => {
  const arquivo = await criarZip('bomba.xlsx', [{ caminho: 'xl/sharedStrings.xml', bytes: 300 * MiB }]);
  expect(await inspecionarZip(arquivo)).toEqual({ ok: false, motivo: 'descompactado_grande_demais' });
}, 30_000);

test('recusa entrada com razão de compressão suspeita', async () => {
  const arquivo = await criarZip('razao.xlsx', [{ caminho: 'xl/worksheets/sheet1.xml', bytes: 50 * MiB }]);
  expect(await inspecionarZip(arquivo)).toEqual({ ok: false, motivo: 'razao_de_compressao_suspeita' });
}, 30_000);

test('aceita arquivo com conteúdo pouco compressível', async () => {
  const arquivo = await criarZip('normal.xlsx', [
    { caminho: 'xl/worksheets/sheet1.xml', bytes: 4 * MiB, aleatorio: true },
    { caminho: 'xl/sharedStrings.xml', bytes: 2 * MiB, aleatorio: true },
  ]);
  expect(await inspecionarZip(arquivo)).toEqual({ ok: true });
});
CenárioComo simularResultado esperado
Bomba de descompressãoZip com 300 MiB de zeros gerado no testerejeitada com descompactado_grande_demais, sem processo filho
Razão de compressão suspeitaEntrada única com 50 MiB de zerosrejeitada com razao_de_compressao_suspeita
Parser que estoura memóriaTeto de heap de 64 MB no ambiente de teste e uma planilha que precisa de maisfalhou com memoria_esgotada, worker vivo, job não repetido
Parser travadoTimeout de 1 segundo no teste e um CSV de 2 milhões de linhasfalhou com tempo_esgotado em cerca de 1 segundo
Duplo cliqueDois POST simultâneos com o mesmo arquivoA mesma importação nas duas respostas e um único job
API durante importação pesadaTeste de carga nas rotas comuns com dez importações de 50 MB em andamentop95 das rotas comuns igual ao da linha de base
Cliente com 60 arquivos60 envios seguidos do mesmo clienteA partir do sexto, 429 com Retry-After; arquivo de outro cliente concluído em segundos

No sistema contábil, os números depois da mudança contam a história. No fechamento seguinte, o p95 da API ficou em 190 milissegundos com importações pesadas em andamento. O arquivo do teste de intrusão passou a ser recusado em 40 milissegundos pela inspeção, sem que nenhum parser fosse chamado. A maior importação levou três minutos em segundo plano, e o tempo de espera p95 de arquivos pequenos ficou abaixo de dez segundos mesmo com o escritório das 60 planilhas ativo. O código de importação, aquele que valida e grava as linhas, praticamente não mudou: o que mudou foi onde ele roda e quais limites o cercam.

FAQ

Perguntas frequentes

Posso usar uma função serverless acionada pelo upload no bucket em vez de fila e worker?

Pode, e é uma boa forma de separar receber de processar: o cliente envia direto ao bucket com URL pré-assinada e o evento de criação do objeto aciona a função. Os mesmos limites continuam necessários, só mudam de nome. A memória configurada da função é o teto de memória, o timeout dela é o teto de tempo e a concorrência reservada é o que impede mil arquivos de dispararem mil execuções contra o banco. A inspeção antes do parser continua obrigatória, e eventos de armazenamento podem ser entregues mais de uma vez, então a idempotência pelo hash ou pela chave do objeto também continua.

Um antivírus não resolve o problema do arquivo malicioso?

Resolve uma parte. O ClamAV e ferramentas parecidas detectam malware conhecido por assinatura, o que importa quando o arquivo vai ser baixado por outras pessoas. Eles não detectam uma bomba de descompressão feita sob medida, uma entidade XML que expande dentro do seu parser, uma fórmula maliciosa em uma célula de texto ou um CSV válido com 40 milhões de linhas. Essas ameaças atacam o seu processamento, não o computador de quem baixa, e por isso a defesa está nos limites e na inspeção do formato. Use o antivírus como mais uma camada, isolado e com limites próprios.

Como avisar o usuário que a importação terminou sem consulta agressiva ao servidor?

A consulta ao endereço de status com intervalo crescente é simples, funciona atrás de qualquer proxy e custa pouco, porque a rota faz uma leitura por chave primária. Se a tela precisa reagir na hora, Server-Sent Events é o próximo passo natural, com a consulta como alternativa quando a conexão cai. Para importações longas, um aviso por e-mail ou notificação no sistema evita que o usuário precise ficar na tela. Em todos os casos, o registro no banco continua sendo a fonte da verdade: o aviso informa, e a tela confirma lendo o status.

O arquivo do usuário não pode decidir quanto recurso a sua API usa

Processar um arquivo dentro da requisição entrega ao usuário o controle sobre CPU, memória e tempo do processo que atende todo o sistema, e funciona apenas enquanto os arquivos forem pequenos e bem-intencionados. A correção não é aumentar limites, é mudar a forma: a API aceita, guarda em quarentena e responde 202; o worker inspeciona antes de abrir e processa em um processo filho com teto de heap e de tempo; falhas determinísticas não são repetidas; e cada cliente tem um teto que impede que ele ocupe a fila de todos. Com testes que geram arquivos hostis e medem a latência da API durante importações pesadas, esses limites deixam de ser suposição. Posso revisar como o seu sistema recebe e processa arquivos, separar a importação da API e montar a inspeção, os limites e os testes que mantêm o serviço de pé no dia do maior arquivo.