Skip to content
אצלי
Go back

Do WAF ao contrato da API: Coraza, nginx e CRS num laboratório testável

Escudo redondo de metal gravado, dois elmos e dois sabres cruzados presos a uma tábua de madeira numa parede branca de museu
Foto de Evgeni Adutskevich na Pexels

Também disponível em inglês: From WAF to API contract: Coraza, nginx and CRS in a testable lab.

Toda API precisa de um WAF. Poucos conseguem responder, com evidência, duas perguntas simples: “essa requisição foi bloqueada por qual regra?” e “ela chegou ou não chegou no backend?”. Este post descreve um laboratório que responde as duas, regra por regra, com testes automatizados.

O lab tem três peças:

  1. um simulador de API determinístico, com dados sintéticos e um endpoint que prova o que chegou nele;
  2. o OWASP Coraza, um WAF compatível com ModSecurity escrito em Go, carregado dentro do nginx como módulo, com o OWASP CRS e um plugin;
  3. um conjunto de regras próprias comentadas, um catálogo, diagramas e uma suíte de testes que valida status e chegada de cada caso.

Tudo está em github.com/Esl1h/coraza-apilab e roda localmente com Docker.

Índice

Parte 1: o simulador de API

Para testar um WAF você precisa de um backend que se comporte como uma API corporativa de verdade (login, CRUD, busca, upload, webhook, GraphQL, rotas administrativas), mas que não tenha nada a perder. Por isso existe o simulador, em duas implementações com o mesmo contrato:

As duas têm os mesmos endpoints, as mesmas variáveis de ambiente, o mesmo envelope de erro e a mesma suíte de testes em curl. Rodar o binário abre a porta na hora: sem banco, sem migração, sem arquivo de configuração. Reiniciar volta ao estado inicial.

O que ele simula

Todo erro sai no mesmo formato:

{"error":{"code":"...","message":"...","request_id":"..."}}

O que faz o lab funcionar: /lab/calls

O simulador guarda em memória as últimas 200 requisições que chegaram nele. Basta subir com LAB_ADMIN_TOKEN e marcar cada requisição com um header X-Test-Case:

LAB_ADMIN_TOKEN=lab-secret ./rs-http-apilab-linux-amd64

curl -H 'X-Test-Case: caso-42' 'http://127.0.0.1:8080/api/v1/search?q=security'

curl -H 'X-Lab-Token: lab-secret' http://127.0.0.1:8080/lab/calls |
  jq '.calls[] | select(.x_test_case == "caso-42")'

Se o caso aparece no buffer, a requisição chegou no backend. Se não aparece, alguém no meio do caminho cortou: proxy, WAF, firewall, timeout. Quando o WAF fica na frente, essa ausência é a prova de que o bloqueio aconteceu de verdade.

Baixando o binário

Os dois projetos publicam o binário em cada release, com SHA256SUMS:

gh release download v1.0.0 --repo Esl1h/rs-http-apilab
sha256sum -c SHA256SUMS
chmod +x rs-http-apilab-linux-amd64
LAB_ADMIN_TOKEN=lab-secret ./rs-http-apilab-linux-amd64   # 0.0.0.0:8080

Troque rs por v para usar a versão em V. Os binários são dinâmicos contra glibc 2.36 ou mais nova (Debian bookworm em diante).

Obtendo o openapi.json

A especificação OpenAPI 3.0.3 completa vem embutida no binário e é servida em dois lugares:

Para salvar o arquivo:

curl -s http://127.0.0.1:8080/openapi.json -o openapi.json
jq '.info, (.paths | keys)' openapi.json

O mesmo funciona através do WAF do laboratório (http://localhost:8080/openapi.json no all-in-one, :8090 no split).

Explorando com Bruno ou Restfox

Com o openapi.json em mãos, a melhor forma de explorar a API manualmente é importar a especificação num cliente de API. Recomendo dois, ambos open source e locais (nada de conta em nuvem para guardar suas coleções):

Dicas para usar os dois contra o laboratório:

Parte 2: o Coraza

O que é

O OWASP Coraza é um motor de WAF em Go, compatível com a linguagem de regras do ModSecurity (SecLang) e com o OWASP CRS. Ele é uma biblioteca: você embute num servidor ou proxy por meio de um conector. Os conectores mais comuns são para Caddy, Envoy/Proxy-Wasm, HAProxy (SPOA) e, o que usamos aqui, nginx.

A cadeia no nginx tem três partes:

  1. coraza: o motor em Go;
  2. libcoraza: o motor exportado como biblioteca C compartilhada (libcoraza.so);
  3. coraza-nginx: o módulo dinâmico do nginx que conversa com a libcoraza.

A libcoraza é carregada com dlopen() dentro de cada worker, depois do fork, e isso vai importar mais adiante. Um runtime Go não sobrevive a um fork, então cada worker inicializa o seu próprio runtime e compila o seu próprio conjunto de regras.

Como uma transação é avaliada

Cada requisição vira uma transação que passa por cinco fases:

  1. Request headers: método, URI, query, headers, IP do cliente.
  2. Request body: o corpo, já interpretado pelo processador certo (JSON, urlencoded, multipart, XML ou RAW).
  3. Response headers.
  4. Response body.
  5. Logging: sempre roda, mesmo se houve bloqueio.

Dentro de cada fase, as regras rodam na ordem em que foram carregadas. Uma regra tem variáveis (onde olhar), um operador (o que procurar), transformações (como normalizar antes) e ações (o que fazer):

SecRule REQUEST_HEADERS:User-Agent "@pmFromFile bad-user-agents.txt" \
    "id:10200,phase:1,deny,status:403,log,t:none,t:lowercase,\
    msg:'Attack tool User-Agent',severity:'CRITICAL'"

Dois jeitos de decidir: deny e pontuação de anomalia

Paranoia levels

O CRS separa as regras em níveis de paranoia (PL1 a PL4). PL1 tem poucos falsos positivos; cada nível acima pega mais coisa e exige mais ajuste. Dá para bloquear num nível e detectar em outro: no lab, bloqueamos em PL1 e registramos até PL2, então o audit log mostra o que subir de nível pegaria antes de você pagar pelos falsos positivos.

Plugins do CRS

O CRS tem um mecanismo de plugins: cada plugin traz arquivos *-config.conf, *-before.conf e *-after.conf, carregados antes e depois das regras do CRS, numa faixa de IDs reservada (9.500.000 a 9.999.999). Há plugins de exclusão para aplicações conhecidas (WordPress, Nextcloud…), de endurecimento e de integração.

Nem todo plugin roda no Coraza. Avaliei alguns:

O que o Coraza não faz

Em troca, ele tem operadores que o ModSecurity não tem e que fazem muita diferença em API:

Parte 3: o laboratório

O repositório coraza-apilab coloca tudo isso junto em dois formatos. Versões usadas, todas fixadas e verificadas por sha256 no build:

Arquitetura e diagramas

Os diagramas abaixo foram gerados a partir do Mermaid do repositório. O código-fonte de cada um, renderizado pelo GitHub, está nos links:

All-in-one: nginx, Coraza e o binário da API no mesmo container. A API escuta em 127.0.0.1:9000 e a porta não é publicada; a única entrada é o nginx. Uma segunda porta, 8081 só em loopback, serve health check e stub_status sem WAF: probe de saúde nunca pode ser bloqueado por falso positivo.

Diagrama do exemplo all-in-one: o cliente acessa a porta 8080 de um único container, onde o nginx com Coraza, CRS e as regras do lab faz proxy_pass para o simulador de API em 127.0.0.1:9000; uma segunda porta, 8081, só em loopback e sem WAF, atende health check e stub_status

Split: WAF e API em containers separados. A API fica só numa rede internal: true: sem porta publicada, sem rota para a internet (o que reduz o estrago de um SSRF) e com container read-only, sem capabilities. É o formato que você usaria em produção, como dois serviços ou como sidecar no mesmo pod.

Diagrama do exemplo split: o cliente acessa o container waf (nginx com Coraza, porta 8090) na rede frontend, que encaminha para o container api na rede backend interna; a API é read-only, sem capabilities, e não tem rota para a internet

Ciclo de vida de uma requisição. Repare na ordem: o nginx descarta HTTP malformado antes do Coraza; o realip resolve o IP real; a fase 1 do Coraza roda antes do rate limit; e a inspeção de resposta segura os headers até o corpo ser inspecionado, para conseguir trocar a resposta por um 403 se encontrar vazamento.

Diagrama de sequência entre cliente, nginx, Coraza e API: limites e realip no nginx, fase 1 do Coraza, rate limit, fase 2 com a avaliação da pontuação de anomalia, proxy para a API, fases 3 e 4 na resposta com bloqueio em caso de vazamento e fase 5 de log

Camadas: quem responde o quê

Um WAF é uma camada e o lab deixa cada controle onde ele funciona melhor:

ProblemaQuem resolve
Cliente lento (slowloris, slow POST)timeouts do nginx
Header ou linha de requisição gigantebuffers do nginx (400/414)
Corpo acima de 8 MiBclient_max_body_size do nginx (413)
Corpo acima de 2 MiBSecRequestBodyLimit do Coraza (413)
Corpo acima de 1 MiBMAX_BODY_BYTES da API (413 com o envelope da API)
IP real atrás de proxyrealip do nginx
Rate limit, força brutalimit_req/limit_conn do nginx (429)
Reputação de IP, ACL, Host, métodosregras do lab no Coraza
Contrato da APIregras do lab no Coraza
Ataques genéricos (SQLi, XSS, RCE, LFI, SSRF…)OWASP CRS
Vazamento na respostaregras do lab + CRS 95x
Autenticaçãoa própria API

Os três limites de tamanho são propositalmente decrescentes, para que a resposta diga quem atendeu. Toda resposta gerada na borda (nginx ou Coraza) usa o envelope da API com um campo a mais, "source":"edge":

{"error":{"code":"waf_blocked","message":"request blocked by the WAF","source":"edge","request_id":"5617d4e0..."}}

O mesmo request_id aparece no access log do nginx, no audit log do Coraza (é o id da transação, via coraza_transaction_id $request_id) e no log da API (enviado como X-Request-ID). Um id, três logs.

Rodando o all-in-one

Requisitos: Docker com Compose v2, make, curl e bash; jq para o explain. O primeiro build baixa e compila a libcoraza e o módulo do nginx, então leva alguns minutos.

git clone https://github.com/Esl1h/coraza-apilab
cd coraza-apilab

make allinone-up            # http://localhost:8080, simulador em Rust
make test-allinone          # 184 verificações
make explain-allinone       # quais regras decidiram cada caso
make audit                  # acompanha o audit log do Coraza
make allinone-down

Para usar o simulador em V, basta a variável:

APILAB=v make allinone-up

Por dentro, o docker/allinone-entrypoint.sh sobe a API como usuário sem privilégio, espera ela responder e só então inicia o nginx. Se qualquer um dos dois processos morrer, o container sai e a política de restart sobe os dois de novo, em vez de deixar o nginx respondendo 502 para sempre.

Experimente na mão:

curl -s localhost:8080/api/v1/users/1                        # 200, chega na API
curl -s "localhost:8080/api/v1/search?q=1'+UNION+SELECT+1--"  # 403, SQLi pelo CRS
curl -s localhost:8080/api/v1/users/abc                      # 400, contrato via @restpath
curl -s localhost:8080/.env                                  # 404, "nunca existiu"
curl -s localhost:8080/lab/response-marker                   # 403, vazamento pego na resposta

Rodando o split

make split-up               # http://localhost:8090
make test-split
make explain-split
make split-down

APILAB=v make split-up      # com o simulador em V

Os dois exemplos usam portas diferentes (8080 e 8090) e podem rodar ao mesmo tempo. make down derruba os dois. Para conferir o isolamento da rede interna:

docker exec coraza-apilab-api curl -s -m3 https://example.com   # falha: sem saída

Ordem de carga das regras

O nginx carrega um único arquivo, coraza/main.conf, que fixa a ordem documentada pelo CRS:

Diagrama da ordem de carga das regras: coraza.conf, crs-setup.conf, configuração e regras before dos plugins, rules-before do lab, regras do CRS, regras after dos plugins e rules-after com exclusões e regras de resposta

  1. coraza.conf: como inspecionar. Acesso ao corpo, processador por Content-Type (JSON, XML, RAW para binário e texto), limites de tamanho e de argumentos, profundidade de JSON (32), inspeção de resposta, audit log em JSON.
  2. crs-setup.conf: bloqueio em PL1, detecção em PL2, limite de anomalia 5, early blocking, métodos, Content-Types e versões HTTP permitidos, limites de argumentos dimensionados pelo contrato da API.
  3. e 4. Plugins: configuração e regras “before”.
  4. rules-before/: as regras do lab, da mais barata e certa para a mais cara, e as exclusões em tempo de execução (ctl:), que precisam rodar antes da regra do CRS que alteram.
  5. CRS.
  6. Plugins “after”.
  7. rules-after/: exclusões em tempo de configuração (SecRuleRemoveById e afins só funcionam sobre regras já carregadas) e regras de resposta.

Toda regra do lab segue o mesmo modelo de comentário: ID, fase, ação, o que faz e por quê.

# 10400 | phase 1 | deny 400
# /api/v1/users/{id}: positive integer, at most 9 digits.
SecRule REQUEST_FILENAME "@restpath /api/v1/users/{id}" \
    "id:10400,\
    phase:1,\
    deny,\
    status:400,\
    log,\
    t:none,\
    msg:'Invalid user id in path',\
    logdata:'id=%{ARGS_PATH.id}',\
    tag:'lab/api-contract',\
    tag:'lab/positive-model',\
    severity:'ERROR',\
    chain"
    SecRule ARGS_PATH:id "!@rx ^[1-9][0-9]{0,8}$" \
        "t:none"

Catálogo de regras

O catálogo completo, em inglês, está em docs/rules.md. Aqui vai o resumo comentado, na ordem em que o Coraza avalia. A coluna “Teste” é o nome do caso na suíte.

Este é o fluxo completo de avaliação, também do repositório:

Fluxograma de avaliação de uma requisição: validação de HTTP pelo nginx, regras de fase 1 do Coraza por arquivo (100 a 150 e CRS), rate limit do nginx, regras de fase 2 (pontuação, schemas, payload, exclusões, CRS e decisão 949110), a API e as regras de resposta das fases 3 e 4

100: quem está falando (100-edge-network.conf)

Decisões que dependem só do IP e da rota. São baratas e rodam primeiro.

IDAçãoO que fazTeste
10000deny 403IP na denylist (data/ip-denylist.txt)ip-denylist
10010engine offGET/HEAD /health e /ready sem query pulam o WAF; vem depois da denylist, então IP banido não ganha nem probehealth
10020deny 403/admin, /internal, /metrics e /lab/calls só da rede administrativa (a “VPN” 10.10.0.0/16)admin-from-internet, admin-from-vpn
10030DetectionOnlyIP do scanner autorizado: tudo é inspecionado e registrado, nada é bloqueado. Um pentest gera achados no audit log em vez de uma parede de 403trusted-scanner-sqli

O IP vem do realip do nginx a partir do X-Forwarded-For. No lab o nginx confia nas faixas privadas para que os testes possam escolher o IP de origem. Em produção, set_real_ip_from lista só os seus balanceadores; confiar numa faixa que o cliente alcança deixa qualquer um escolher o próprio IP e passar por cima de allowlist, denylist e rate limit.

110: o HTTP faz sentido para esta API (110-protocol.conf)

IDAçãoO que fazTeste
10100deny 400Host que a API não atende (cache poisoning, reset de senha envenenado, roteamento para vhost interno)host-not-served
10110deny 400Mais de 40 headerstoo-many-headers
10120deny 400Content-Type, Authorization ou Host duplicados: o WAF pode olhar o primeiro e o backend usar o últimoduplicate-content-type
10130deny 405PUT, PATCH e DELETE fora das rotas que aceitamdelete-on-reports

O CRS complementa com a allowlist global de métodos (911100, PROPFIND vira 403), versões HTTP (920430, HTTP/1.0 vira 403) e Content-Types (920420). TRACE nem chega no Coraza: o próprio nginx devolve 405.

120: quem diz estar falando (120-user-agent.conf)

IDAçãoO que fazTeste
10200deny 403Ferramentas de ataque que não estão na lista do CRS (ffuf, hydra, patator, katana…)scanner-ffuf
10210deny 403User-Agent padrão de biblioteca HTTP, só na rota de loginlibrary-on-login, library-elsewhere
10220+3 pontosSem User-Agentno-user-agent
10230+2 pontosSem Acceptno-accept, no-ua-no-accept

O par 10220 e 10230 é o exemplo didático de pontuação de anomalia: cada um sozinho passa (3 e 2 pontos, abaixo do limite 5); juntos somam 5 e o CRS bloqueia na regra 949110. As duas são de fase 2 porque a regra 901200 do CRS zera as pontuações na fase 1, depois que o arquivo do lab já rodou: pontos somados na fase 1 antes do CRS somem.

130: o que está sendo pedido (130-uri.conf)

Modelo positivo aplicado a paths: em vez de listar URLs ruins (impossível), descrever as que existem.

IDAçãoO que fazTeste
10300deny 414URI acima de 2048 bytesuri-too-long
10310deny 400%2f, %5c ou dupla codificação no path (bypass de ACL e traversal)encoded-slash
10320deny 404Arquivos sensíveis: .git, .env, backups, dumps, chavesdotgit, backup-file
10330deny 404Rota fora do inventário da APIdotenv, not-in-inventory
10340deny 404Nome em /static/ fora da allowliststatic-not-allowed

Por que 404 e não 403: um 403 confirma “tem algo aqui e está protegido”; um 404 não dá nada para o scanner seguir. A regra 10330 é, sozinha, a mais eficiente contra varredura cega, e ainda funciona como documentação do inventário: rota nova que não for declarada ali fica inacessível.

140: o contrato da API (140-api-contract.conf)

É aqui que o WAF deixa de ser “assinaturas genéricas” e vira um fiscal do contrato: o que estiver fora do OpenAPI é recusado, exista ou não uma assinatura para aquele ataque.

IDAçãoO que fazTeste
10400 a 10403deny 400@restpath para /users/{id}, /tickets/{id}, /reports/{id}/download e /lab/status/{code}, validando o formato de cada segmentouser-id-not-numeric, user-id-sqli, report-id-format, status-code-format
10410, 10411deny 400Busca: q até 100 caracteres, limit de 1 a 50search-q-too-long, search-limit-range
10420deny 400/lab/slow?ms= acima de 2000 (DoS de lógica de negócio: a API aceita 10 s)slow-above-cap
10421deny 400/lab/large?bytes= acima de 256 KiB (amplificação)large-above-cap
10430 a 10432deny 415Content-Type por rota: JSON, formulário, uploadjson-route-wrong-ct, form-route-wrong-ct, upload-wrong-ct
10440 a 10442deny 400@validateSchema nos corpos de criação de usuário, ticket e loginmass-assignment, role-not-in-enum, ticket-too-many-tags, login-extra-field

Os JSON Schemas usam additionalProperties: false. É a defesa de borda contra mass assignment (OWASP API3:2023): um {"name":"x","is_admin":true} morre no WAF antes de o backend ter a chance de vincular um campo inesperado.

Repare que user-id-sqli (/api/v1/users/1 OR 1=1) é bloqueado pela regra de contrato, não pela de SQLi: o id não é um inteiro. Modelo positivo pega ataque sem precisar reconhecê-lo.

150: credenciais (150-auth.conf)

O WAF não autentica ninguém; isso é trabalho da API. Ele recusa credencial malformada cedo e mantém segredos fora do próprio log.

IDAçãoO que fazTeste
10500deny 401Esquema diferente de Bearer (Basic, NTLM…)basic-auth
10510deny 401Token Bearer fora do alfabeto do RFC 6750 ou do tamanho esperado; o log registra só o tamanhobearer-malformed
10520deny 401/auth/me e /auth/logout sem Authorizationme-without-token
10530ctlRemove o corpo da requisição do audit log na rota de login (senhas)

160: o que vem dentro (160-payload.conf)

O CRS já cobre as classes genéricas. Estas regras cobrem ataques específicos às funcionalidades desta API.

IDAçãoO que fazTeste
10600deny 403Introspecção de GraphQL (__schema, __type)graphql-introspection
10610deny 403Batching de aliases no GraphQL (mais de 20)graphql-aliases
10620deny 403SSRF: URL para loopback, RFC 1918, link-local, IPv6 local ou esquema não HTTP em webhook e /admin/configssrf-private, ssrf-metadata
10630deny 403Upload com extensão executável ou de scriptupload-php
10640deny 403Sintaxe de template ({{ }}, ${ }, <% %>) em texto livre de ticket e feedbackssti-ticket
10650deny 403Campos de privilégio (role, is_admin…) em /admin/configadmin-config-role

E os casos que ficam com o CRS: SQLi (942xxx, inclusive libinjection), XSS (941xxx), RCE (932xxx), LFI (930xxx), prototype pollution (934130), Log4Shell (944xxx), scanner conhecido (913100) e o Referer inválido do plugin (9524110).

190 e 900: exclusões (190-runtime-exclusions.conf, 900-config-exclusions.conf)

RegraO que faz
10900Expect: 100-continue nas rotas de upload e webhook deixa de ser header restrito para a 920450
10910GET /openapi.json não passa pela regra de canário 19100
SecRuleRemoveById 9524139Contorno de um crash do Coraza com o plugin referer-hardening

As três têm história, contada abaixo.

910: o que a API devolve (910-response.conf)

IDFaseAçãoO que fazTeste
191004deny 403Marcador canário LAB_RESPONSE_MARKER_7f3a no corpo da respostacanary-in-body
191103logO mesmo canário em header: só registra (padrão “detecção antes de bloquear”)canary-in-body
191204deny 403Chave privada ou access key da AWS no corpo da respostasecret-in-body
191303log5xx do backend (observabilidade)backend-503
191403logResposta não JSON numa rota /api/v1

O canário é a ideia mais útil para levar para produção: plante um registro falso e único no banco (um usuário, um cartão de teste) e bloqueie qualquer resposta que o contenha. Se ele aparecer, alguém está despejando dados que não deveria. No teste canary-in-body, a API responde (o caso aparece no /lab/calls), mas o cliente recebe 403.

Testes: prova de chegada

A suíte tests/waf-test.sh manda 91 requisições, legítimas e maliciosas, cada uma com um X-Test-Case único. Depois lê o /lab/calls e confere, caso a caso:

No fim, dois testes de rate limit: dez logins seguidos do mesmo IP (o sétimo em diante leva 429) e 80 requisições seguidas para a zona geral.

== 110 protocol
PASS case-031-host-not-served                     status 400
...
== proof of arrival: /lab/calls
PASS case-026-ip-denylist                         reached API: no
PASS case-090-canary-in-body                      reached API: yes
== rate limiting (nginx limit_req; Coraza has no cross-request state)
PASS login burst (10 in a row) ->  401 401 401 401 401 401 429 429 429 429
PASS general burst (80 in a row) -> 25 x 429
== 184 passed, 0 failed

Os IPs de origem são simulados com X-Forwarded-For: cada caso usa um IP próprio de 192.0.2.0/24 (para os limites por IP não se misturarem), e os casos de ACL usam 10.10.1.5 (rede administrativa), 10.10.99.10 (scanner autorizado) e 203.0.113.7 (denylist).

Rodando:

make test-allinone                                 # contra :8080
make test-split                                    # contra :8090
BASE_URL=http://localhost:8080 VERBOSE=1 tests/waf-test.sh   # mostra o corpo de cada resposta

E para saber qual regra decidiu cada caso, o tests/explain.sh lê o audit log em JSON:

make explain-allinone CASE=no-ua-no-accept
case-044-no-ua-no-accept  status=403
    10220  Request without User-Agent
    10230  Request without Accept header
    920320  Missing User Agent Header
    949110  Inbound Anomaly Score Exceeded (Total Score: 5)

A 920320 é uma regra de PL2 do CRS: como detectamos em PL2 e bloqueamos em PL1, ela aparece no log mas não soma na pontuação de bloqueio. É o “o que PL2 pegaria” funcionando.

Escrevendo uma regra nova

O ciclo para criar uma regra no lab:

  1. Escreva a regra no arquivo da etapa certa, com o comentário no modelo (ID, fase, ação, por quê).
  2. make check: compila o conjunto de regras com a libcoraza da imagem, sem subir o nginx, e mostra a contagem de regras.
  3. Adicione dois casos em tests/waf-test.sh: o ataque que a regra deve pegar e a requisição legítima mais parecida que ela não pode pegar.
  4. make allinone-up && make test-allinone, depois make explain-allinone CASE=... para confirmar que foi a sua regra que decidiu, e não outra por acidente.

Afinando: falsos positivos reais

O fluxo de ajuste que vale para produção:

  1. Suba com SecRuleEngine DetectionOnly (ou limite de anomalia alto) e tráfego real.
  2. Leia o audit log: cada bloqueio que teria acontecido, com regra, variável e valor.
  3. Para cada falso positivo, escreva a exclusão mais estreita possível: uma rota, uma regra, um argumento.
  4. Adicione um teste para a requisição legítima e outro para o ataque que a regra ainda precisa pegar.
  5. Mude para On e vá baixando o limite.

Exclusão em tempo de execução (ctl:ruleRemoveTargetById, em rules-before/190) depende da requisição e precisa rodar antes da regra alvo. Exclusão em tempo de configuração (SecRuleUpdateTargetById, SecRuleRemoveById, em rules-after/900) vale para toda requisição e precisa ser carregada depois que a regra alvo existe.

Dois falsos positivos apareceram de verdade durante a construção:

Expect: 100-continue. O caso que manda 1,5 MiB para o webhook deveria receber o 413 da API e recebeu 403. O audit log mostrou a CRS 920450, “HTTP header is restricted by policy (/expect/)”: o curl, como a maioria dos clientes, manda Expect: 100-continue antes de corpos grandes, e o CRS trata Expect como header restrito. A regra 10900 remove só o header Expect da 920450, só quando o valor é exatamente 100-continue, só nas duas rotas que recebem corpos grandes. Qualquer outro valor, ou qualquer outra rota, continua bloqueado.

O openapi.json vazando o canário. Ao baixar a especificação para importar no Bruno, veio 403. A especificação documenta o endpoint /lab/response-marker e, portanto, contém a string do canário; a regra de DLP fez exatamente o que devia. A especificação é pública por definição, então a regra 10910 tira a 19100 só para GET /openapi.json. A 19120 (segredos) continua valendo ali.

O que quebrou no caminho

Cada item abaixo custou pelo menos uma execução quebrada.

  1. nginx -t não valida as regras. O conector compila as regras dentro de cada worker, depois do fork. Uma regra quebrada passa no nginx -t, todos os workers morrem e as requisições ficam penduradas com o master “de pé”. A solução foi um validador de 20 linhas em C contra a libcoraza, o docker/coraza-check.c, executado em /docker-entrypoint.d/ (o container não sobe com regra inválida) e no make check.
  2. Include relativo com glob não carrega nada, sem erro. Include crs/rules/*.conf carregou 19 regras em vez de cerca de 700. O main.conf usa caminhos absolutos e o coraza-check imprime a contagem de regras, que é o jeito de perceber.
  3. A libcoraza 1.8.0 vem com o Coraza 3.7.0. O 3.8.1 é release de segurança: crash com JSON muito aninhado, bypass de multipart strict e de t:jsDecode, entre outros, e traz o SecArgumentsLimit. O Dockerfile atualiza o motor com go get antes de compilar a libcoraza.
  4. Um plugin derrubava o WAF. O referer-hardening usa setvar:'!tx.1' (remoção de variável). No Coraza 3.8.1 isso desreferencia um ponteiro nulo em internal/actions/setvar.go e o panic do Go aborta o worker do nginx: qualquer requisição com header Referer matava um worker. O contorno é remover a regra 9524139, que só limpa capturas antes da regra seguinte capturar de novo. O teste referer é a regressão. Lição: WAF é código no caminho da requisição; teste com os seus plugins e com headers reais.
  5. Corpo de tipo desconhecido vira urlencoded. O CRS força a inspeção do corpo e o Coraza cai no parser URLENCODED: um upload binário de 1 MiB virou milhares de “argumentos” lixo, estourou o SecArgumentsLimit e custou cerca de 10 s por MiB. A regra 200007 escolhe o processador RAW para application/octet-stream e text/plain.
  6. Pontos de fase 1 somem. Já citado: a 901200 zera a pontuação depois que as regras do lab de fase 1 rodaram.
  7. O audit log não registrava os 404 do próprio WAF. O SecAuditLogRelevantStatus exclui 404 (ruído de API), então as regras de arquivo sensível forçam ctl:auditEngine=On.
  8. RE2, não PCRE. Sem lookahead: ^(?:5|4(?!04)) teve de ser escrito por extenso.
  9. Arquivo criado como root quebra os workers. A primeira versão do coraza-check rodava como root e criava o debug log; os workers (usuário nginx) não conseguiam abrir. O validador roda como o usuário do worker.
  10. Memória é por worker. Cada worker carrega o seu WAF compilado: 80 a 105 MiB de RSS por worker no lab, contando o override por location (coraza_rules dentro de uma location cria mais uma instância). worker_processes está em 2, não em auto; numa máquina de 24 núcleos seriam 24 cópias.
  11. O audit log é sensível. A parte B (headers da requisição) inclui Authorization. O lab tira o corpo do login do audit (regra 10530), mas o arquivo ainda precisa do mesmo controle de acesso que você daria a credenciais.

Números

Medidos na máquina do lab:

Conclusão

Um WAF sem teste é um ato de fé: a regra existe, o arquivo foi carregado, mas ninguém sabe dizer se a requisição maliciosa parou na borda ou chegou no backend. Este laboratório troca essa fé por evidência. Com um X-Test-Case em cada requisição e o /lab/calls do simulador, cada bloqueio vira um fato verificável, com o status certo, a regra certa no audit log e a ausência no backend. Essa disciplina expôs o que a documentação não mostra: um plugin oficial que derrubava workers com um header comum, regras que o nginx -t aprovava sem compilar e dois falsos positivos que apareceram em menos de um dia, ainda com tráfego sintético. A outra lição é que o WAF rende mais quando deixa de ser só uma lista de assinaturas e passa a fiscalizar o contrato da API: inventário de rotas, @restpath e JSON Schema barram ataques que nenhuma assinatura conhece, enquanto rate limit, limites de tamanho e autenticação ficam na camada em que funcionam melhor.

Nada disso substitui o trabalho de afinar o WAF com o tráfego real da sua API, mas deixa esse trabalho com método: subir em modo de detecção, ler o audit log, escrever exclusões estreitas e transformar cada ajuste em um caso de teste que impede a regressão. O repositório coraza-apilab está aberto para isso. Clone, suba um dos exemplos, importe o openapi.json no Bruno ou no Restfox e tente passar pelo WAF. Se conseguir, abra uma issue com o caso de teste.


Share this post on:

Previous Post
From WAF to API contract: Coraza, nginx and CRS in a testable lab
Next Post
Um editor Markdown visual para o Standard Notes, com a nota guardada como Markdown puro