monteirotf.com
Firewall e Segurança de Edge

Sentinel (Firewall de Bots na Edge)

Firewall de bots que escrevi em Cloudflare Workers: cada requisição recebe uma pontuação de 0 a 100 com sinais do plano gratuito, e o rate limit por IP encolhe conforme a suspeita aumenta.

Bloqueado na edge
Tráfego malicioso
Fora do caminho (waitUntil)
Registro de eventos
$0 (free tier)
Custo de API de segurança

O problema

Ataques de bots e scrapers consumiam banda e requisições em excesso, inflando o custo da infraestrutura sem uma ferramenta de mitigação viável no free tier.

A abordagem

Desenvolvi o Sentinel, um firewall de bots executado na edge com Cloudflare Workers e Durable Objects. Cada requisição recebe uma pontuação por assinaturas de TLS, versão de HTTP, headers do navegador e rede de origem, passa por um rate limit adaptativo por cliente e sai com um veredito na própria edge: libera, desafia ou bloqueia. O registro acontece em ctx.waitUntil, fora do caminho da resposta.

01

Arquitetura

Cliente HTTP → Worker (index.js): requisiçãoWorker (index.js) → score.js: headers e request.cfWorker (index.js) → RateLimiter DO: score e parâmetros de configscore.js → Veredito: score vs limiaresRateLimiter DO → Veredito: limited, count, limitWorker (index.js) → Stats DO: ctx.waitUntil, fora do caminho da respostaWorker (index.js) → logger.js: logDecisionCliente HTTP · navegador, curl ou scannerCliente HTTPnavegador, curl ou scannerWorker (index.js) · rotas /, /api/stats e /api/checkWorker (index.js)rotas /, /api/stats e /ap…score.js · heurísticas puras, pontuação 0 a 100score.jsheurísticas puras, pontua…RateLimiter DO · um Durable Object por IP, janela deslizante adaptativaRateLimiter DOum Durable Object por IP,…Veredito · ALLOW 200, CHALLENGE/BLOCK 403, RATE_LIMITED 429VereditoALLOW 200, CHALLENGE/BLOC…Stats DO · contadores globais e últimos 40 eventos para o dashboardStats DOcontadores globais e últi…logger.js · uma linha JSON por decisão, IP truncadologger.jsuma linha JSON por decisã…
  1. 01Cliente HTTP → Worker (index.js): requisição
  2. 02Worker (index.js) → score.js: headers e request.cf
  3. 03Worker (index.js) → RateLimiter DO: score e parâmetros de config
  4. 04score.js → Veredito: score vs limiares
  5. 05RateLimiter DO → Veredito: limited, count, limit
  6. 06Worker (index.js) → Stats DO: ctx.waitUntil, fora do caminho da resposta
  7. 07Worker (index.js) → logger.js: logDecision
02

Decisões

  1. 01

    Só sinais do plano gratuito, sem Bot Management pago

    Por quê Não usei request.cf.botManagement. Tudo vem de headers, metadados de TLS/HTTP e da rede de origem (ASN e threatScore). A ideia era que as heurísticas fossem o produto, não um recurso pago da Cloudflare.

  2. 02

    Rate limit que se adapta à pontuação

    Por quê O orçamento por IP é baseLimit x (1 - min(0,85, score/100)), com piso de 3 requisições. Assim o abuso fica caro sem punir usuários reais: um navegador limpo recebe a janela inteira e o cliente mais suspeito cai para 15% dela.

  3. 03

    Rate limiter que falha aberto

    Por quê Uma falha no Durable Object ou no storage não pode negar acesso a um usuário real. Por isso o erro é registrado com IP truncado e a requisição segue como não limitada.

    Custo Enquanto a falha durar, nenhum limite é aplicado. A resposta interna sai marcada com degraded: true.

  4. 04

    Estatísticas fora do caminho quente

    Por quê O registro no Stats DO roda em ctx.waitUntil, com catch próprio, para nunca adicionar latência nem quebrar o veredito devolvido ao cliente. Pontuação e veredito são o fluxo principal; stats e logs ficam secundários e isolados.

  5. 05

    IP truncado antes de logar ou armazenar

    Por quê Escrevi coarsenIp para remover o último octeto IPv4 ou o último grupo IPv6, de modo que o IP completo nunca seja logado nem guardado no Stats DO. O objetivo é a conformidade com a GDPR.

03

Ao vivo

Simulador · Sentinelmesma função de score do Worker
Headers enviados
Transporte
Rede de origem (ASN)
pontuação67
bloqueia
403 Forbidden
  • +12UA de navegador sem Accept-Language
  • +22diz ser Chrome 128 mas não manda Sec-CH-UA (UA forjado)
  • +18Chrome moderno sem metadados Sec-Fetch-*
  • +15navegador moderno falando HTTP/1.1 (esperado h2/h3)
04

Números com fonte

Cada número aponta para o arquivo do repositório de onde saiu.

Limiar de CHALLENGE
30
fonte: src/config.js, wrangler.toml
Limiar de BLOCK
60
fonte: src/config.js, wrangler.toml
Janela do rate limit
10000 ms
fonte: src/config.js, wrangler.toml
Limite base por janela
20 requisições / 20 requests
fonte: src/config.js, wrangler.toml
Redução máxima do orçamento
0,85 (até 15% do limite) / 0.85 (down to 15% of the limit)
fonte: wrangler.toml
Piso do limite
3 requisições / 3 requests
fonte: src/config.js, src/ratelimiter.js
Peso: User-Agent de automação conhecida
65
fonte: src/config.js, src/score.js
Peso: sem User-Agent
50
fonte: src/config.js, src/score.js
Versão do Chrome a partir da qual Client Hints são exigidos
90
fonte: src/config.js, src/score.js
Eventos recentes guardados no Stats DO
40
fonte: src/config.js, src/stats.js
Tamanho máximo do User-Agent guardado
80 caracteres / 80 characters
fonte: src/config.js, src/index.js
Parâmetros configuráveis por variável de ambiente
24
fonte: src/config.js
Testes unitários (Vitest)
90 passando / 90 passing
fonte: tests/*.test.js, README.md (confirmado com vitest run / confirmed with vitest run)
Cobertura de linhas de score.js, ratelimiter.js, config.js e logger.js
100%
fonte: vitest.config.js (vitest run --coverage)
05

Stack

  • Cloudflare Workers
  • Durable Objects
  • JavaScript
  • Vitest