---
title: "Sprint sécurité MCP livré : v1.9.0, 10 chantiers, écosystème durci"
description: "Sprint de hardening livré le 9 mai 2026 sur arleo.eu MCP : C1-C10 complets en une session, hugo-mcp v1.9.0 publiée. Recap victoire + zoom sur 2 chantiers pédagogiques (TLS interne, token rotation)."
url: "https://www.arleo.eu/posts/sprint-securite-mcp-livre/"
language: "fr"
datePublished: "2026-05-09T18:42:54+02:00"
dateModified: "2026-05-15T23:51:25+02:00"
tags: ["security","mcp","hardening","hugo","retex"]
categories: ["infrastructure"]
contentSignal: "ai-train=no, search=yes, ai-input=yes"
---

# Sprint sécurité MCP livré : v1.9.0, 10 chantiers, écosystème durci
Sprint de hardening livré le 9 mai 2026 sur arleo.eu MCP : C1-C10 complets en une session, hugo-mcp v1.9.0 publiée. Recap victoire + zoom sur 2 chantiers pédagogiques (TLS interne, token rotation).



## 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](/posts/roadmap-sprint-securite-mcp/). `hugo-mcp` est désormais en **v1.9.0** ([GitHub Release](https://github.com/jmrGrav/hugo-mcp/releases/tag/v1.9.0)), 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](https://github.com/jmrGrav/hugo-mcp/blob/main/CHANGELOG.md) et le commit [1404f83](https://github.com/jmrGrav/hugo-mcp/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 :

```json
{
  "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 :

```bash
$ python token_mgr.py create --label "claude.ai prod"
Token: <redacted-issued-token>   # ← 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) :

```bash
# 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 :

```bash
uvicorn main:app \
    --host 0.0.0.0 --port 8000 \
    --ssl-keyfile /etc/hugo-mcp/server.key \
    --ssl-certfile /etc/hugo-mcp/server.crt
```

Côté NUC `hugo-mcp-proxy`, on parle HTTPS et on **vérifie le cert** :

```python
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 :

1. **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.
2. **Hygiène** : se forcer à faire TLS partout évite l'erreur classique "j'ai oublié de basculer en HTTPS pour la prod".
3. **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](https://claude.com/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)](/posts/strategie-4-mcp-vs-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](https://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.


## Tags

- security
- mcp
- hardening
- hugo
- retex

## Categories

- infrastructure
