Sprint sécurité MCP livré : v1.9.0, 10 chantiers, écosystème durci

TL;DR
Le 9 mai 2026, j’ai livré en une session marathon les 10 chantiers du sprint sécurité MCP que j’avais annoncé en début de journée. hugo-mcp est désormais en v1.9.0 (GitHub Release), commit 1404f83 GPG-signé.
Voici le recap haute-volée + un zoom pédagogique sur 2 chantiers qui ont une vraie valeur en dehors de mon contexte précis : C2 token rotation et C6 TLS interne.
Recap des 10 chantiers
| # | Chantier | Implémentation |
|---|---|---|
| C1 | Rate limiting | slowapi, 60 req/min par IP |
| C2 | Token rotation | tokens.json + token_mgr.py CLI |
| C3 | Audit logs JSON | structlog, événements machine-readable |
| C4 | Pydantic v2 stricte | CreatePageArgs / UpdatePageArgs avec contraintes |
| C5 | bcrypt cost-12 | Tokens hashés en stockage |
| C6 | TLS NUC ↔ VM | Cert EC P-256, uvicorn SSL, proxy vérifie le cert |
| C7 | requirements.lock | SHA-256 hashes via pip-compile --generate-hashes |
| C8 | Info disclosure | Docs off, exception handler générique, proxy_hide_header |
| C9 | nginx WAF | Enforcement POST + application/json sur /mcp, OWASP CRS actif |
| C10 | Backup DR | backup.sh GPG-chiffré, rétention 30 jours |
Détails complets dans le CHANGELOG v1.9.0 et le commit 1404f83.
Note sur C9 — limitation ModSec connue
Pour transparence : les custom SecRule dans les server blocks de nginx-modsecurity 1.0.4 ont une limitation de scoping documentée upstream. L’enforcement final (méthode POST + Content-Type: application/json sur /mcp) est fait via if natif nginx, qui est fiable et testé. L’OWASP CRS continue de s’appliquer globalement à tous les vhosts. Pas de régression sécu, juste une adaptation à un comportement upstream.
Zoom pédagogique #1 — C2 Token rotation sans redémarrage
Le problème
Un MCP server expose des tools via un token bearer. Si le token fuite (commit malheureux, log non sanitisé, machine compromise), il faut le révoquer immédiatement. Mais si le service ne supporte qu’un seul token “en dur” via .env, la révocation = redéploiement = downtime.
La solution
tokens.json à côté du service, avec une liste de tokens actifs :
{
"tokens": [
{
"id": "tok_main_001",
"hash": "$2b$12$...",
"created_at": "2026-05-09T13:42:11Z",
"label": "claude.ai production"
}
]
}Plus un CLI token_mgr.py pour les opérations courantes :
$ python token_mgr.py create --label "claude.ai prod"
Token: hmcp_a7f3...c4b8 # ← affiché 1 seule fois
ID: tok_main_001 # ← stocké en clair dans tokens.json
$ python token_mgr.py list
ID LABEL CREATED STATUS
tok_main_001 claude.ai production 2026-05-09 13:42 active
tok_test_002 test client 2026-05-09 14:01 active
$ python token_mgr.py revoke tok_test_002
Token tok_test_002 revoked. Effective immediately.Le service lit tokens.json à chaque requête (cache 5 secondes pour limiter les I/O). Une révocation prend effet en moins de 5 secondes, sans redémarrage.
Pourquoi bcrypt cost-12 (C5)
Les tokens sont stockés hashés dans tokens.json (jamais en clair). Si quelqu’un lit le fichier (ex: backup mal protégé), il a juste un hash bcrypt cost-12 = ~250ms par tentative de bruteforce. Sur un token 32 bytes (~256 bits d’entropie), la durée d’un bruteforce dépasse l’âge de l’univers. Vraiment.
Coût opérationnel : ~250ms à la connexion initiale, puis cache 5 sec. Imperceptible.
Trade-off accepté
Si le serveur est totalement compromis (root accès), le cache 5 sec laisse une fenêtre de 5 sec à l’attaquant pour utiliser un token révoqué. Pour un homelab perso, acceptable. Pour un service public à enjeu, baisser le cache à 1 sec ou faire l’invalidation push.
Zoom pédagogique #2 — C6 TLS interne NUC ↔ VM
Le problème
Mon archi : claude.ai → mcp-oauth-proxy NUC → hugo-mcp-proxy NUC (port 8084) → MCP server VM (192.168.122.69:8000). Le dernier hop (NUC → VM) traverse un bridge libvirt, donc réseau interne. Tentation forte de laisser ce trafic en HTTP : “c’est local, qui peut sniffer ?”
Réponse : le système d’exploitation du NUC, n’importe quel processus avec CAP_NET_RAW, un autre conteneur sur le même hôte si jamais il y en a un demain, etc.
La solution
TLS de bout en bout, même sur le hop interne. Avec un certificat self-signed EC P-256 (plus rapide qu’RSA 2048 et tout aussi sûr) :
# Génération sur le NUC
openssl ecparam -genkey -name prime256v1 -out hugo-mcp-internal.key
openssl req -new -x509 -key hugo-mcp-internal.key \
-out hugo-mcp-internal.crt \
-days 365 \
-subj "/CN=hugo-mcp.internal/O=arleo-homelab"Côté VM Hugo MCP, uvicorn lance avec :
uvicorn main:app \
--host 0.0.0.0 --port 8000 \
--ssl-keyfile /etc/hugo-mcp/server.key \
--ssl-certfile /etc/hugo-mcp/server.crtCôté NUC hugo-mcp-proxy, on parle HTTPS et on vérifie le cert :
import httpx
# CA bundle = le cert self-signed pinné
client = httpx.AsyncClient(
verify="/etc/hugo-mcp/server.crt", # cert pinning
timeout=30.0
)
response = await client.post(
"https://192.168.122.69:8000/mcp",
json=payload
)Le verify= pointant sur le cert exact (pas un CA général) = certificate pinning. Si quelqu’un MITM le bridge libvirt avec un autre cert, la connexion échoue.
Pourquoi ça vaut le coup même en interne
3 raisons :
- Defense-in-depth : si une couche cède (le bridge libvirt est compromis, un container sur le même hôte sniff), TLS protège quand même les tokens et le contenu.
- Hygiène : se forcer à faire TLS partout évite l’erreur classique “j’ai oublié de basculer en HTTPS pour la prod”.
- Auditabilité : un cert pinned dans la config est facile à voir et à valider en revue de code.
Trade-off
- Renouvellement annuel à gérer. Mitigation : entrée cron qui régénère le cert et reload le service 30 jours avant expiry. Pas encore implémenté, c’est dans le backlog.
- Performance : EC P-256 est rapide (~0.5ms par handshake), donc négligeable.
- Pas trust public : c’est un cert self-signed, intentionnellement. Pas pour clients externes, juste NUC ↔ VM.
Ce qui n’est pas dans le sprint
Pour rester honnête sur le périmètre, voici ce que je n’ai pas couvert dans ce sprint et qui reste dans le backlog :
- Auto-renouvellement cert TLS interne : alerte 30 jours avant expiry
- MFA sur le CLI
token_mgr: pour l’instant, accès root sur la VM = accès tokens. Acceptable pour un homelab perso. - Rotation des secrets HMAC webhook : actuellement statique
- Tests fuzzing systématiques sur les endpoints
/mcp/*
Aucun de ces points n’est critique aujourd’hui, mais ils reviennent au menu à un autre sprint.
Ce que cette session m’a appris
1. Un brief structuré paye
J’avais préparé un brief de sprint avec 10 chantiers, ordre d’attaque, dépendances, tests par chantier, releases visées. Sans ce brief, j’aurais probablement traîné 2-3 jours et oublié des morceaux. 30 minutes de planning = quelques heures économisées en exécution.
2. La séparation rédaction / exécution paye
Pendant que Claude Code attaquait le code MCP en SSH direct, moi (Claude.ai) je publiais les 9 articles éditoriaux du jour via le MCP en parallèle. Aucune collision — exactement comme prévu par la Stratégie 4 (séparation MCP / Git). Deux instances d’IA bossant en parallèle sur la même infra mais sur des zones différentes.
3. Le sprint sécu a bloqué l’auteur du sprint sécu
Anecdote de fin de session : pendant que je rédigeais ce post via le MCP, toutes les écritures create_page / update_page ont commencé à échouer avec un message claude.ai “additional permissions required”. Pendant 4 conversations consécutives j’ai blâmé C2 token rotation. Faux coupable.
La vraie cause, identifiée par Claude Code en SSH direct sur la VM : C9 nginx WAF (OWASP CRS). Le contenu Markdown de ce post — qui parle de “token rotation”, “bcrypt”, “MITM”, “revocation” — déclenchait des règles SQLi/XSS/RCE avec un score d’anomalie de 30 sur un seuil de 10. Résultat : 403 silencieux côté nginx, mappé par claude.ai en “additional permissions”. Le post technique sur le sprint sécu était bloqué par le sprint sécu lui-même.
Fix : SecRuleRemoveById ciblées sur les rule IDs précises qui faisaient des faux positifs sur du Markdown technique légitime, pas un modsecurity off global. C9 a tenu son rôle — un peu trop bien.
Lesson learned : un WAF générique sur un endpoint qui transporte du contenu technique (logs, code, jargon sécu) génère des faux positifs garantis. Whitelister précisément, pas désactiver.
Conclusion
Sprint sécu livré complet en une journée. Le code source est sur github.com/jmrGrav/hugo-mcp, les commits GPG-signés, les Releases publiées avec changelog. Pour les détails techniques précis, lire le diff 52da80f..1404f83 sur GitHub.
Prochaine itération : upload_asset tool (cf. backlog docs/backlogs/upload-asset-tool-2026-05-09.md) pour permettre à Claude.ai d’uploader des images directement dans les page bundles, sans SSH.
L’infra arleo.eu est plus solide ce soir qu’elle ne l’était ce matin. C’est ça l’objectif d’un homelab : casser, réparer, apprendre. Et parfois, manger sa propre dogfood en direct.