Pular para o conteúdo
zechim
Voltar para o blog
6 min de leituravercelnextjssegurancabotsengenharia

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.

  1. Matcher do middleware excluindo paths com ponto (incluindo .php)
  2. Catch-all dinâmica que aceita qualquer locale
  3. 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

IfOperatorValue
Request PathEnds with.php
OR Request PathEnds with.aspx
OR Request PathEnds with.asp
OR Request PathEnds with.jsp
OR Request PathEnds with.cgi
OR Request PathEnds with.env
ThenDeny

Regra 2: probes de CMS e admin

IfOperatorValue
Request PathStarts with/wp-
OR Request PathStarts with/xmlrpc
OR Request PathStarts with/phpmyadmin
OR Request PathStarts with/adminer
OR Request PathStarts with/cpanel
ThenDeny

Regra 3: probes de arquivos escondidos

IfOperatorValue
Request PathStarts with/.git
OR Request PathStarts with/.aws
OR Request PathStarts with/.ssh
OR Request PathStarts with/.svn
OR Request PathStarts with/.DS_Store
ThenDeny

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:

  1. 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.

  2. 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.