Programação Por Equipe DevBras

Capa do artigo: Gerar PDF no servidor sem vazar dados: escape, isolamento e link que exige login

Gerar PDF no servidor sem vazar dados: escape, isolamento e link que exige login

Monte o HTML com dados escapados, renderize sem rede nem JavaScript, confira o dono do documento antes de gerar e guarde o PDF que precisa ser sempre o mesmo.

O portal da Academia Ritmo tem um botão "Baixar fatura em PDF". No teste, perfeito. Em produção, três problemas apareceram no mesmo mês: um aluno com < no nome quebrou o layout do PDF; trocando o número no endereço /faturas/1043.pdf para 1044, qualquer pessoa baixava a fatura de outro aluno; e a fatura de março, baixada de novo em abril, mostrava a data de abril como data de emissão.

A resposta curta: gerar PDF no servidor é montar um documento a partir de dados escapados, renderizado num ambiente sem acesso à rede nem execução de scripts, entregue só depois de conferir que o documento pertence a quem pede. Documentos que precisam ser idênticos sempre, como fatura e recibo, são gerados uma vez e guardados.

Por que o PDF herda os problemas da web

A forma mais comum de gerar PDF hoje é escrever um HTML com CSS de impressão e mandar um navegador sem interface (headless), como o Chromium controlado pelo Puppeteer, transformar em PDF. É prático: o time já sabe HTML. Mas o renderizador é um navegador de verdade. Se o dado do usuário entra no HTML sem escape, ele vira marcação. Uma tag <img> ou <iframe> injetada faz o navegador do servidor buscar um endereço, de dentro da sua rede, inclusive endereços internos que nunca deveriam ser alcançáveis de fora. Esse ataque se chama SSRF.

Bibliotecas que desenham o PDF diretamente, sem HTML, como Spire.PDF ou QuestPDF no .NET, tratam texto como texto e não têm esse risco de injeção. Mas os outros dois problemas da Academia Ritmo valem para qualquer biblioteca: autorização e reprodutibilidade não são responsabilidade do gerador, são suas.

Como gerar o PDF com segurança

  1. Confira a sessão e o dono do documento antes de qualquer outra coisa.
  2. Busque o documento por identificador, filtrando pelo dono na consulta.
  3. Escape todo dado que entra no HTML.
  4. Renderize sem JavaScript e sem rede: fontes e imagens embutidas.
  5. Limite tempo e concorrência da geração.
  6. Use datas do registro, nunca "agora", no conteúdo do documento.
  7. Guarde documentos definitivos em armazenamento privado e sirva a cópia guardada.

Exemplo em Node.js com Puppeteer

const express = require('express');
const puppeteer = require('puppeteer');

function esc(valor) {
  return String(valor)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

function reais(centavos) {
  return (centavos / 100).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}

function montarHtml(fatura) {
  return '<!doctype html><html lang="pt-BR"><head><meta charset="utf-8">' +
    '<style>@page { size: A4; margin: 15mm; } body { font-family: sans-serif; }</style>' +
    '</head><body>' +
    '<h1>Fatura ' + esc(fatura.numero) + '</h1>' +
    '<p>Aluno: ' + esc(fatura.alunoNome) + '</p>' +
    '<p>Emitida em: ' + esc(fatura.emitidaEm.toLocaleDateString('pt-BR', { timeZone: 'America/Sao_Paulo' })) + '</p>' +
    '<p>Valor: ' + esc(reais(fatura.valorCentavos)) + '</p>' +
    '</body></html>';
}

let navegador;
async function obterNavegador() {
  if (!navegador) navegador = await puppeteer.launch();
  return navegador;
}

async function gerarPdf(html) {
  const browser = await obterNavegador();
  const page = await browser.newPage();
  try {
    await page.setJavaScriptEnabled(false);
    await page.setRequestInterception(true);
    page.on('request', (req) => {
      if (req.url().startsWith('data:')) req.continue();
      else req.abort();
    });
    await page.setContent(html, { waitUntil: 'load', timeout: 10000 });
    const pdf = await page.pdf({ format: 'A4', printBackground: true });
    return Buffer.from(pdf);
  } finally {
    await page.close();
  }
}

Cada peça tem um papel. A função esc transforma o < do nome do aluno em texto. O JavaScript desligado impede que um script injetado rode. A interceptação aborta qualquer requisição que não seja data:, então nem uma imagem injetada consegue buscar endereço interno; por isso logotipo e fontes vão embutidos em base64 no próprio HTML. O timeout impede que um documento problemático segure o processo. O Buffer.from existe porque versões recentes do Puppeteer devolvem Uint8Array, e o Express trata isso diferente de um Buffer no res.send.

A rota que confere o dono

const app = express();

app.get('/faturas/:id/pdf', exigirLogin, async (req, res) => {
  const fatura = await db.buscarFaturaDoAluno(req.params.id, req.user.alunoId);
  if (!fatura) return res.sendStatus(404);

  let pdf = await armazenamento.ler('faturas/' + fatura.id + '.pdf');
  if (!pdf) {
    pdf = await gerarPdf(montarHtml(fatura));
    await armazenamento.gravar('faturas/' + fatura.id + '.pdf', pdf);
  }

  res.set('Content-Type', 'application/pdf');
  res.set('Content-Disposition', 'attachment; filename="fatura-' + fatura.numero + '.pdf"');
  res.set('Cache-Control', 'private, no-store');
  res.send(pdf);
});

As funções exigirLogin, db e armazenamento são do seu projeto. O ponto central é que buscarFaturaDoAluno filtra pelo id e pelo aluno logado na mesma consulta. Fatura de outro aluno devolve 404, igual a uma que não existe, sem confirmar que o número é válido. O id no endereço pode até ser sequencial; a proteção vem da conferência, não de esconder o número. Ainda assim, um identificador opaco, como UUID, dificulta a tentativa em massa.

Documento definitivo não se gera de novo

Fatura, recibo e declaração precisam ser o mesmo arquivo hoje e daqui a um ano. Se o PDF é gerado a cada clique, qualquer mudança no template, no cadastro do aluno ou no relógio muda o documento. Foi assim que a fatura de março ganhou data de abril: o template usava new Date(). A data de emissão sai do registro (fatura.emitidaEm), e o arquivo gerado na primeira vez é guardado e servido dali em diante.

Esse armazenamento é privado: nada de pasta pública do servidor web nem bucket aberto, como explica arquivo em blob não fica público porque o upload funcionou. Se o documento precisa provar que não foi alterado, a assinatura digital é o passo seguinte, tratado em assinar PDF sem entregar a chave. Para relatórios com tabelas longas, o CSS de impressão tem cuidados próprios, vistos em relatório impresso sem cortar a última coluna.

Capacidade: um navegador por pedido não escala

Chromium headless consome memória considerável. Abrir um navegador inteiro por requisição derruba o servidor no primeiro pico, como o fechamento do mês, quando todos baixam fatura ao mesmo tempo. Reaproveite uma instância e abra uma página por documento, como no exemplo, e limite quantas gerações rodam em paralelo com uma fila. Para lotes grandes, gere em segundo plano e avise quando o arquivo estiver pronto.

Erros comuns

  • Concatenar dado do usuário no HTML sem escape. O layout quebra e, com o navegador do servidor, abre caminho para SSRF.
  • Deixar o renderizador acessar a rede. Uma tag injetada busca endereços internos a partir do servidor.
  • Buscar o documento só pelo id. Trocando o número no endereço, qualquer um baixa o documento alheio.
  • Usar a data atual no conteúdo. O mesmo documento muda a cada download.
  • Salvar o PDF em pasta pública. O link vaza e o arquivo fica acessível sem login para sempre.
  • Abrir um navegador headless por requisição. Memória esgotada no primeiro pico de uso.

Checklist

  • A rota exige login e busca o documento filtrando pelo dono.
  • Todo dado entra no HTML escapado.
  • O renderizador roda sem JavaScript e sem acesso à rede.
  • Fontes e imagens estão embutidas no HTML.
  • Há tempo máximo e limite de gerações simultâneas.
  • A data do documento vem do registro, não do relógio.
  • Documentos definitivos são gerados uma vez e guardados em armazenamento privado.
  • A resposta usa Cache-Control: private, no-store.

Copie o endereço de download de um PDF privado do seu sistema e abra numa janela anônima, sem login: o arquivo baixa?

Avalie este artigo

Referência: https://www.e-iceblue.com/Introduce/free-pdf-component.html