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
ifdentro do controller. O Laravel te dá Policies e Gates de graça, e o código faziaif ($user->role === 'admin')em 30 lugares diferentes. Basta esquecer um papel novo em um únicoifpra 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
- 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.
- Os problemas caros quase nunca são sofisticados. Não era algoritmo ruim. Era GIF de 7 MB, query repetida e cache desligado.
- 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".
- 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/
Fonte: https://foxfit.io/