Contenu

Post-mortem : Cloudflare Bot Management bloquait les webhooks MCP

Le symptôme

Je viens de finaliser un endpoint webhook dans le hugo-mcp-proxy qui recevra des notifications de GitHub à chaque push sur le repo arleo.eu. Implementation propre : HMAC-SHA256, rate limiting, IPAddressAllow GitHub ranges côté systemd.

Test fonctionnel depuis un client externe :

$ curl -X POST https://mcp-hugo.arleo.eu/webhook/test \
    -H "Content-Type: application/json" \
    -d '{"test": true}'

Réponse : 403 Forbidden.

Curieux. Le service tourne, l’IP source de mon test est dans le whitelist, le HMAC est correct. Pourquoi 403 ?

Investigation côté serveur

Logs nginx côté NUC :

$ sudo tail -100 /var/log/nginx/mcp-hugo.access.log | grep webhook

Vide. Aucune requête n’arrive sur nginx.

Logs mcp-oauth-proxy :

$ sudo journalctl -u mcp-oauth-proxy -n 100 | grep webhook

Vide aussi. La requête n’arrive pas jusqu’au service.

Soit elle est bloquée par firewall avant nginx (CrowdSec ou ufw), soit en amont par Cloudflare.

La vérité côté Cloudflare

Diagram Diagram

J’ouvre le dashboard Cloudflare → Security → Events. Filtre sur mcp-hugo.arleo.eu :

2026-05-08 14:23:11  Action: Block
                     Source IP: 82.65.X.X
                     Country: FR
                     User-Agent: python-httpx/0.28.1
                     Rule: Cloudflare Bot Management
                     Score: 6 (likely automated)

Cloudflare Bot Management détecte mon curl -X POST parce que (probablement) j’ai fait le test depuis un script Python qui utilisait httpx. L’UA python-httpx/0.28.1 est dans une liste de UAs typiquement automatisés.

Le webhook est légitimement automatisé (c’est le but) mais Cloudflare Bot Management ne fait pas la différence entre un bon et un mauvais bot par défaut.

Pourquoi ce comportement par défaut

Cloudflare Bot Management protège des bots abusifs : crawlers SEO agressifs, scrapers de prix, brute-force credentials, etc. Il considère que tout client non-navigateur est suspect par défaut, sauf signal contraire (réputation, Bot ID, JS challenge réussi).

Pour un endpoint webhook (qui DOIT recevoir du trafic automatisé), ce comportement est exactement à l’envers de ce qu’on veut.

Le fix : règle de bypass scopée

Dashboard Cloudflare → Security → WAF → Custom Rules → Create rule :

Name: Allow webhook traffic to mcp-hugo
Expression: 
  (http.host eq "mcp-hugo.arleo.eu") 
  and (http.request.uri.path matches "^/webhook/")
  and (ip.src in {160.79.104.0/21 140.82.112.0/20})
Action: Skip
Skip products: Bot Management

Trois conditions cumulées :

  1. Host explicite — pas de bypass global, juste sur le sous-domaine MCP
  2. Path matché — uniquement les routes /webhook/*, pas les autres tools MCP
  3. IP source — les ranges officiels claude.ai (160.79.104.0/21) et GitHub webhooks (140.82.112.0/20)

Le triple filtre garantit que ce bypass ne peut être abusé que par un client sur les ranges réseau officiels + qui frappe le bon path + sur le bon host. Un attaquant random ne peut pas en bénéficier.

Test de validation

$ curl -X POST https://mcp-hugo.arleo.eu/webhook/test \
    -H "Content-Type: application/json" \
    -d '{"test": true}'

{"status": "ok", "received": true}

Réponse en 200ms.

Comment j’ai trouvé les ranges claude.ai

Cloudflare ne documente pas explicitement les ranges sortants de claude.ai (Anthropic). Pour les identifier :

  1. J’ai capturé les IPs sources dans les logs Cloudflare quand un appel MCP normal arrivait depuis Claude.ai
  2. Toutes étaient dans 160.79.104.0/21
  3. Vérifié sur RIPE/ARIN : ce range appartient bien à Anthropic
  4. J’ai posé la question à Anthropic via support — confirmation officielle qu’ils utilisent ce range pour les fetchers (web_fetch, MCP, etc.)

Pour les webhooks GitHub : ils publient leurs ranges dans la doc officielle (https://api.github.com/meta). Le range principal est 140.82.112.0/20.

Lessons learned

1. Bot Management = ennemi par défaut des webhooks

Cloudflare Bot Management est utile pour 95% des routes (browser-facing). Pour les 5% restants (webhooks, API publique, MCP), il faut explicitement créer un bypass scopé.

Règle : tout endpoint conçu pour recevoir du trafic automatisé doit être audité dans Cloudflare Events les 24h suivant son déploiement.

2. Triple condition pour les bypass

Un bypass Cloudflare scopé sur (host, path, IP source) est résilient. Si un seul de ces critères pouvait suffire, ce serait un vecteur d’attaque.

J’ai vu des règles Cloudflare en prod qui font juste Action: Skip if path matches /webhook/. Erreur : n’importe qui peut maintenant frapper /webhook/anything depuis n’importe quelle IP avec n’importe quel UA.

3. Les logs Cloudflare ne mentent pas, mais ils ne sont pas en temps réel

Le dashboard Cloudflare Events a ~30 secondes de latence. Si tu fais un curl puis tu ouvres le dashboard, tu peux ne rien voir et conclure à tort que la requête n’a pas été bloquée par Cloudflare. Attendre une minute, refresh.

4. UA python-httpx est suspect en 2026

Comme python-requests, aiohttp, etc. La plupart des WAF marquent ces UAs comme “automated” par défaut. Si tu fais un client custom, deux options :

  • Custom UA descriptif : User-Agent: arleo-monitor/1.0 (+https://arleo.eu/security.txt) — lisible, traçable
  • Range IP whitelist côté serveur — plus robuste si l’attaquant peut spoofer l’UA

J’ai fini par adopter les deux : UA descriptif et IP whitelist côté Cloudflare.

Conclusion

403 Forbidden sur un webhook légitime, cause Bot Management. Fix : règle Custom triple-condition (host + path + IP source range). Total : ~1h de debug, dont 40 min à regarder les mauvais endroits (nginx, mcp-oauth-proxy, systemd) avant de penser à Cloudflare.

Lesson finale : quand un appel HTTP retourne un code WAF (403, 429), toujours commencer par regarder les events du WAF. Le serveur applicatif n’a probablement même pas vu la requête.