Bloqueando scanners de vulnerabilidade no Vercel e Next.js
Logs cheios de HTTP 500 em paths como /wp-Blogs.php que nem existem no site. A correção usa duas camadas: Vercel Firewall na borda + middleware no Next.js. Causa raiz e padrões pra bloquear.
por Zechim
Estava olhando os logs do Vercel quando vi isso:
500 GET /wp-Blogs.php Error: Page changed from static to dynamic
500 GET /css.php Error: Page changed from static to dynamic
500 GET /chosen.php Error: Page changed from static to dynamic
500 GET /1.php Error: Page changed from static to dynamic
500 GET /wp-mail.php Error: Page changed from static to dynamic
404 GET /wp-content/index.php
Sequência inteira de scans de WordPress chegando no zechim.com, que é um site Next.js sem uma linha de PHP. Bot script kiddie clássico, esperado em qualquer site público. O que NÃO era esperado: o status code 500.
Esses requests deveriam retornar 404 limpo. Estavam retornando 500. Cada um custando uma invocação de Vercel Function. Pollutando os logs. E carregando uma mensagem de erro estranha: "Page changed from static to dynamic".
Demorei um tempo pra entender. Mas a causa raiz é interessante, e o fix tem duas camadas que vale documentar.
Por que 500 e não 404
O middleware do Next.js (no nosso caso, src/proxy.ts rodando next-intl) tinha um matcher assim:
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"]
Esse pattern EXCLUI paths que têm ponto. Faz sentido em primeira leitura: assets estáticos (/icon.svg, /favicon.ico) têm ponto, e a gente não quer middleware rodando neles.
Mas /wp-Blogs.php também tem ponto. Então o middleware pula. O request vai direto pro Next.js router.
Aí começa a confusão. O Next.js procura uma página que case com /wp-Blogs.php. Não acha nada estático. Cai na rota catch-all dinâmica: /[locale]/[...rest]. Tenta casar:
locale="wp-Blogs.php"rest=[]
A página catch-all é simples: se locale é válido, renderiza 404. Mas o layout pai ([locale]/layout.tsx) tem generateStaticParams retornando só pt-BR e en-US. Esse layout foi pré-renderizado estaticamente pra esses dois locales no build.
Quando chega um request com locale = "wp-Blogs.php", o layout não está pré-renderizado pra ele. Next.js tenta uma renderização nova, dinâmica. Mas o layout pai era pra ser estático. Conflito.
Resultado: HTTP 500, mensagem "Page changed from static to dynamic". Cada scan vira uma function invocation que crasha.
Resumo: três coisas conspiraram pra produzir o 500.
- Matcher do middleware excluindo paths com ponto (incluindo
.php) - Catch-all dinâmica que aceita qualquer locale
- Layout pai estaticamente pré-renderizado só pros locales reais
A correção em duas camadas
A solução mais simples seria mudar o matcher do middleware pra incluir as bait paths. Funciona, mas adiciona compute em cada scan (ainda invoca uma edge function, mesmo que rejeite rápido).
A solução com defense-in-depth é melhor: bloquear na borda E no middleware.
Camada 1: Vercel Firewall (na borda, sem compute)
Vercel Pro tem firewall built-in. No dashboard do projeto, em Firewall > Rules, dá pra criar regras que rejeitam requests antes de chegar em qualquer função.
Três regras que configurei.
Regra 1: extensões de script comuns em probes
| If | Operator | Value |
|---|---|---|
| Request Path | Ends with | .php |
| OR Request Path | Ends with | .aspx |
| OR Request Path | Ends with | .asp |
| OR Request Path | Ends with | .jsp |
| OR Request Path | Ends with | .cgi |
| OR Request Path | Ends with | .env |
| Then | Deny |
Regra 2: probes de CMS e admin
| If | Operator | Value |
|---|---|---|
| Request Path | Starts with | /wp- |
| OR Request Path | Starts with | /xmlrpc |
| OR Request Path | Starts with | /phpmyadmin |
| OR Request Path | Starts with | /adminer |
| OR Request Path | Starts with | /cpanel |
| Then | Deny |
Regra 3: probes de arquivos escondidos
| If | Operator | Value |
|---|---|---|
| Request Path | Starts with | /.git |
| OR Request Path | Starts with | /.aws |
| OR Request Path | Starts with | /.ssh |
| OR Request Path | Starts with | /.svn |
| OR Request Path | Starts with | /.DS_Store |
| Then | Deny |
Use Deny, não Challenge. Nenhum bot vai resolver o CAPTCHA, e nenhum usuário legítimo está pedindo /wp-Blogs.php. Deny retorna 403 rápido sem gastar nada do downstream.
Camada 2: middleware do Next.js (fallback)
Se o firewall não estiver ativo (free tier, ou regra desativada acidentalmente), o middleware ainda pega. No src/proxy.ts:
import createMiddleware from "next-intl/middleware";
import { NextRequest, NextResponse } from "next/server";
import { routing } from "./i18n/routing";
const intlMiddleware = createMiddleware(routing);
const SCAN_PATH_PATTERNS = [
/\.php$/i,
/\.aspx?$/i,
/\.jsp$/i,
/\.cgi$/i,
/^\/wp-/i,
/^\/xmlrpc(\.|$)/i,
/^\/phpmyadmin(\/|$)/i,
/^\/\.(env|git|aws|ssh|svn|hg|DS_Store)(\b|\/|$)/i,
/^\/(adminer|webmail|cpanel|plesk)(\/|$)/i,
];
export default function middleware(req: NextRequest) {
const path = req.nextUrl.pathname;
if (SCAN_PATH_PATTERNS.some((re) => re.test(path))) {
return new NextResponse("Not Found", {
status: 404,
headers: { "content-type": "text/plain" },
});
}
return intlMiddleware(req);
}
export const config = {
matcher: [
"/((?!api|_next|_vercel|.*\\..*).*)",
"/:path*.php",
"/:path*.aspx",
"/wp-admin/:path*",
"/wp-content/:path*",
"/xmlrpc.php",
"/phpmyadmin/:path*",
],
};
Duas mudanças importantes:
-
O matcher agora tem múltiplas entries. A primeira é a original (paths reais do app). As outras incluem os patterns de bot explicitamente, pra que cheguem no middleware ao invés de bypassar.
-
A função detecta scan patterns e responde 404 antes de chamar next-intl.
O efeito
Antes do fix, logs típicos por hora:
- ~3.000 requests de scan
- ~2.500 retornando 500 (
Page changed from static to dynamic) - ~500 retornando 404 (paths que o Next.js conseguia matchear como 404)
- Cada um: 1 Function invocation
Depois do fix:
- Mesmos ~3.000 requests por hora
- 0 retornando 500
- Todos retornando 404 ou 403
- Compute por scan: ~zero (firewall pega antes da função)
Logs limpos. Custo cai pra basicamente nada nessa categoria de tráfego.
Por que duas camadas
Firewall sozinho daria conta. Middleware sozinho também daria. Mas combinar tem três vantagens.
Resiliência operacional. Se você desativar uma regra do firewall por engano, o middleware ainda pega. Se algum padrão novo aparecer e você atualizar primeiro no código, o firewall não bloqueia, mas o middleware sim.
Migração de host. Se um dia mudar de Vercel pra Cloudflare Pages, AWS Amplify, Netlify ou self-host, o middleware viaja com o código. As regras do firewall ficam pra trás.
Visibilidade dividida. O firewall mostra bloqueios numa aba dedicada (Firewall > Activity). O middleware aparece em function logs. Conseguir ver os dois separadamente é útil pra entender o perfil de quem tá batendo.
O que não fazer
Algumas tentativas que parecem boas mas pioram a situação.
Tentar capturar TODOS os bots possíveis. Listas exaustivas viram bagunça e quebram em casos legítimos. Cobre os 90% mais comuns (.php, wp-*, .env) e deixa o resto pro 404 normal.
Logar ou alertar em cada bloqueio. Você vai receber milhares de alertas por dia. Logue agregado (contagem por padrão por hora) ou nada. Bot scan ruidoso é o estado padrão da internet pública. Não é um sinal acionável.
Mover pra um WAF de terceiros. Cloudflare, AWS WAF e similares são bons, mas pra um site Next.js no Vercel a combinação built-in de Firewall + middleware cobre 99% dos casos com zero infra extra. Só vale o WAF se você tem regras de bloqueio mais sofisticadas (rate limit por IP, geo-blocking, anti-DDoS).
Tomada
A pergunta não é "como bloqueio os bots". Bots vão sempre tentar. A pergunta é "como faço com que eles não me custem nada e não polluam meus logs". Firewall + middleware juntos resolvem isso.
Se você roda Next.js no Vercel e tem site público em produção, gasta 15 minutos configurando as três regras de firewall acima. Não precisa nem mexer no código se não quiser, só a camada de borda já corta 99% do problema.
E confira seus logs de tempos em tempos. 500s escondidos atrás de tráfego de bots são o tipo de bug que ninguém vai reportar pra você, mas que tá te custando dinheiro silenciosamente.
Se sua infra Next.js precisa de uma auditoria parecida, ou se você tá em produção e nunca olhou direito pros logs de bot, vale uma conversa.