1

727 MB de GIF, 65 `->get()` e zero teste de autorização: o que eu achei auditando um SaaS Laravel de verdade

Faz um tempo que eu venho trabalhando num projeto chamado FoxFit — um SaaS de avaliação física e gestão pra personal trainer, nutricionista e academia. A ideia é simples: tirar o profissional daquela planilha de Excel de 2011 e entregar avaliação, dieta, treino, agenda e financeiro num lugar só, com PDF bonito no final e conta para o aluno ver seu progresso.

E, honestamente, o produto tá bem legal. Só que esse post não é sobre o produto. É sobre o que aconteceu quando eu sentei e passei um pente-fino de segurança e performance no código.

Spoiler: foi humilhante e educativo na mesma proporção.

O que tem "embaixo do capô"

Pra contextualizar o tamanho da coisa:

  • Laravel 12 / PHP 8.2
  • ~19 mil linhas de PHP em app/, routes/, config/ e migrations
  • 93 views Blade, ~25 mil linhas
  • 84 migrations, 31 models, 30 controllers
  • 278 commits
  • Frontend em JavaScript puro + CSS na mão (Tailwind entrou só no build via Vite)
  • Integrações: gateway de pagamento com PIX, Resend pra e-mail, Google Calendar, WhatsApp via API, dompdf pros relatórios
  • 6 papéis de usuário: master, admin, academia, nutricionista, manager e aluno

Sim, vanilla JS. Sem React, sem Vue, sem Livewire. E antes que alguém venha me linchar: pra esse tipo de app, com Blade fazendo o server-side render, funcionou muito bem. A tela carrega rápido, não tem hydration, não tem build de 4 minutos. O preço é que você escreve mais código repetido e não tem componentização de verdade. Foi uma troca consciente e eu faria de novo.

Parte 1: a auditoria de performance

Essa foi a mais divertida, porque os problemas eram todos óbvios em retrospecto e nenhum era algorítmico. Não tinha nenhum loop O(n²) escondido. O sistema simplesmente pagava caro por coisa barata.

Alguns achados:

727 MB de GIF. Isso mesmo. 871 arquivos GIF de demonstração de exercício dentro de public/, o maior com 7,5 MB, servidos direto pelo servidor de aplicação. Uma tela de treino com 10 exercícios podia transferir 30 a 80 MB. Imagina o aluno abrindo isso no 4G dentro da academia. GIF é literalmente o pior formato possível pra animação: sem compressão temporal entre frames, paleta de 256 cores, sem streaming — o navegador baixa o arquivo inteiro antes de mostrar o primeiro frame. Converter pra MP4/WebM derruba isso em 90–97%.

65 ->get() contra 3 paginate(). Praticamente toda listagem carregava a tabela inteira na memória do PHP. Funciona liso com 50 registros. Com 50 mil, o servidor morre.

A mesma query rodando 5 vezes na mesma página, porque o layout base repetia a consulta em pedaços diferentes do Blade. Mais 8 a 15 queries redundantes por página, e N+1 dentro de view.

Polling de checkout a cada 5 segundos, batendo numa API externa de pagamento, síncrono e sem timeout. Cada usuário na tela de pagamento virava uma metralhadora de requisição HTTP externa.

Cache, sessão e fila tudo no MySQL. 3 a 5 escritas no banco por request só de infraestrutura.

E o meu favorito, porque é o mais burro de todos: deploy sem php artisan optimize. Sem cache de config, de rota, de view. 15 minutos de trabalho pra derrubar 30–50% da CPU por request. Quinze. Minutos.

No total foram 31 pontos mapeados. Os 5 primeiros sozinhos representam algo entre 75 e 90% do consumo de recurso por usuário ativo.

Parte 2: a auditoria de segurança

Essa doeu mais.

Não vou entrar em detalhe de nenhuma falha específica — o sistema tá em produção e o plano de correção tá rodando, então dar mapa do tesouro aqui seria burrice. Mas dá pra falar das classes de problema, que é onde tá a lição de verdade:

  • IDOR em todo lugar. A rota checava se você tava logado, e não se aquele recurso era seu. /avaliacoes/{id} responde bonito pro ID do aluno de outro profissional.
  • Autorização espalhada em if dentro do controller. O Laravel te dá Policies e Gates de graça, e o código fazia if ($user->role === 'admin') em 30 lugares diferentes. Basta esquecer um papel novo em um único if pra abrir um buraco.
  • Webhook de pagamento sem verificar assinatura. Se o endpoint aceita qualquer POST, qualquer pessoa libera a própria assinatura.
  • Upload confiando na extensão. Sempre valide MIME real, renomeie o arquivo e, de preferência, guarde fora da pasta pública.
  • Rota de debug esquecida em produção. Aquela que você criou pra ver log rápido e nunca apagou.
  • Zero rate limiting. Nem no login, nem no reset de senha, nem em nada.
  • Cobertura de teste de autorização: praticamente zero. 3 arquivos em tests/Feature/.

Esse último item é o que eu realmente levei pra vida. Corrigir um IDOR é fácil. O difícil é garantir que ele não volte daqui a 3 meses quando alguém mexer no controller. A única rede que segura isso é um teste bobo:

$this->actingAs($outroUsuario)
     ->put("/clients/{$clienteAlheio->id}", [...])
     ->assertForbidden();

Um teste desse por rota que mexe em dado de terceiro. É chato de escrever, ninguém gosta, e é a diferença entre "corrigimos" e "corrigimos pra sempre".

O que eu tirei disso

  1. Feature entrega valor, arquitetura entrega sobrevivência. O FoxFit cresceu rápido porque o foco era entregar funcionalidade — e isso é certo no começo. Só que chega um ponto em que a dívida cobra juros, e ela cobra tudo de uma vez.
  2. Os problemas caros quase nunca são sofisticados. Não era algoritmo ruim. Era GIF de 7 MB, query repetida e cache desligado.
  3. Segurança em app com múltiplos papéis é problema de arquitetura, não de checklist. Se autorização não é uma camada única e obrigatória, ela vai falhar em algum lugar. Não é "se", é "onde".
  4. Auditar código dos outros (ou o seu de 6 meses atrás) é o exercício mais barato de sênioridade que existe.

E aí?

Fica a pergunta pra quem já passou por isso: em que momento vocês param de empilhar feature e param pra pagar dívida técnica? Eu ainda não achei a régua. Sempre parece cedo demais até o dia em que fica tarde demais.

Link: https://foxfit.io/

Carregando publicação patrocinada...
3

O preço é que você escreve mais código repetido e não tem componentização de verdade.

poxa, dá pra fazer componentização com blade mesmo usando html e css puros.

para cada componente eu tinha uma css-class ex: forms-select, payment-card, ..., usava scss organizado em arquivos, cada arquivo era um módulo. (o bundle final era um arquivão gigantesco, mas bem organizado)

e cada componente front bem organizado nos blade components.

1