nas-runbooks/common/mcp-nas-security-audit.md

14 KiB
Raw Blame History

Audit sécurité mcp-nas — RCE root anonyme exposée Internet (2026-07-08)

Tags : infra, securite, mcp-nas, rce, docker-socket, secrets, rotation Périmètre : conteneur linux-mcp-nas (mcp-nas.nd.i234.me, port hôte 3042→8000, réseau Docker n8n). Complète : common/audit-securite-reseau-nas-20260703.md (couche réseau/WAN). Ici = couche applicative. Statut (MàJ 2026-07-09 manche 4) : P0 fermés + durcissement manche 3 + ROTATION des credentials fuités faite (Portainer, Gitea bolbol, n8n, Baserow, 3 logins web Hermes, NyoraNotes, DSM/sudo, tokens Gitea). Reste : SMTP OVH (externe), nettoyage docs/skills agents restants, re-scoper 1 token Gitea, run non-root. Détail en bas.

Verdict

Compromission totale possible par un attaquant anonyme sur Internet. Chaîne vérifiée depuis un poste externe, sans aucun credential :

  1. Endpoint MCP sans authentification. POST https://mcp-nas.nd.i234.me/mcpinitialize répond, tools/list renvoie run_command. Le bloc nginx (deploy/nginx-reverse-proxy.conf) fait proxy_pass http://localhost:3042 sans auth_basic, sans allowlist IP, sans vérif de token. Les routes OAuth du serveur (/registerclient_id:"public", /token, /authorize, /.well-known/...) sont des stubs décoratifs : elles ne valident aucun bearer. FastMCP n'a aucun auth provider. → shell arbitraire pour tout le monde.
  2. Évasion vers root hôte via docker.sock. Le conteneur tourne en root (uid=0) avec /var/run/docker.sock monté (RW). L'API Docker est joignable (curl --unix-socket /var/run/docker.sock http://localhost/version OK). → créer un conteneur privilégié montant / = root sur le NAS. Le binaire docker n'est pas installé mais le socket suffit (curl). Le serveur MCP n'utilise jamais ce socket (aucune référence dans le code) → mount inutile.
  3. Pivot latéral. Clés SSH privées /root/.ssh/claude_oversight_contabo{,_v2,_v3} dans le conteneur → accès VPS Contabo.
  4. 2e chemin root hôte. Le conteneur ssh vers Best0f@172.17.0.1:22222 (host-gateway) avec le mot de passe sudo passé en clair → sudo hôte.

audit.log = coffre à secrets en clair

/mnt/docker/linux-mcp-nas/audit.log (volume hôte RW, lisible par la RCE) journalise chaque commande entière. Relevé au 2026-07-08 : mot de passe sudo NAS en clair 266×, 102 URLs http://bolbol:MDP@.../repo.git, mots de passe n8n / Portainer, 127 clés Bifrost (x-bf-vk), 19 Bearer. Le mot de passe maître est réutilisé (même préfixe pour sudo / n8n / Portainer). → un attaquant cat le log et récupère tout le trousseau.

Autres surfaces

  • Volumes RW : /volume1/docker monté RW → tous les .env de l'infra (43 conteneurs) lisibles ET modifiables par la RCE. Doit être :ro.
  • Denylist (DENY_ANCHORED/DENY_GLOBAL dans linux_mcp_server.py) contournable trivialement : segment après &&/;, find -delete, python -c, docker.sock, ssh+sudo. C'est de la défense en profondeur, pas une frontière de sécurité — ne jamais s'y fier.
  • Contournement Gitea-API-HTTP (quand git absent du NAS) : utilise le mot de passe du compte bolbol complet dans l'URL de clone (→ audit.log + argv visible en ps), au lieu d'un token scopé. Sur-privilège (accès à tous les repos) + fuite.
  • Gemini : aucune fuite. Le serveur ne référence ni Gemini ni Bifrost (grep code = 0 résultat) ; la sortie texte ne part que vers NyoraNotes. La frontière « exclusif Claude » tient au niveau code : aucune commande mcp-nas n'alimente un prompt Gemini (Gemini n'est câblé que pour la vision dans Bifrost, et mcp-nas ne produit pas d'image).

Correctifs priorisés

P0 — urgence (RCE root exposée) :

  1. Couper l'exposition publique : retirer la règle nginx mcp-nas.nd.i234.me OU la restreindre au réseau WireGuard uniquement (pas de proxy_pass depuis le WAN). Si un accès distant reste nécessaire : auth_request nginx ou un secret partagé obligatoire (header) + implémenter une vraie validation bearer côté serveur.
  2. Supprimer le mount docker.sock du docker-compose.yml (jamais utilisé par le code).
  3. Run non-root : user: "1026:100" + cap_drop: [ALL] + security_opt: no-new-privileges.
  4. Ne plus embarquer le mot de passe sudo dans les commandes ; sortir les clés SSH du conteneur (ou les monter en lecture seule depuis un secret) ; monter /mnt/docker en :ro (garder RW uniquement le sous-chemin strictement nécessaire).
  5. Rédiger les secrets avant écriture dans audit.log (regex password/token/bearer/x-bf-vk → ***) + chmod 600 + rotation du fichier.
  6. ROTATION complète des credentials fuités : mot de passe sudo NAS Best0f, n8n, Portainer bestof, compte Gitea bolbol, toutes les clés virtuelles Bifrost, clés SSH Contabo.

P1 : token Gitea repo-scopé (au lieu du mot de passe bolbol) via credential helper, jamais dans l'URL ; rate-limiting/fail2ban sur l'endpoint.

P2 : healthcheck ne teste que /health pas /mcp (connu) ; dérive doc MCP_MAX_OUTPUT_CHARS (README dit 2000, défaut code 8000).

Correctifs APPLIQUÉS le 2026-07-08

  • P0 #1 — exposition fermée (nginx). /etc/nginx/conf.d/http.mcp-nas.conf (backup .bak-audit-20260708).
    • Piège connecteur claude.ai : il n'accepte PAS de header Authorization brut, uniquement OAuth (Client ID/Secret). Le gate Bearer ne marche donc pas avec le connecteur. → auth par CHEMIN SECRET : location /<secret>/ { proxy_pass http://localhost:3042/; } (le / final strippe le préfixe). Le connecteur utilise l'URL https://mcp-nas.nd.i234.me/<secret>/mcp, champs OAuth vides.
    • /mcp direct reste gaté par Authorization: Bearer <secret> (pour scripts/curl). /health + stubs OAuth ouverts.
    • Le <secret> (64 hex) N'EST PAS dans ce runbook — il est chez Nabil (mémoire Claude Code + fichier NAS linux-mcp-nas/ scratch). Rotation = regénérer + MAJ nginx + MAJ URL connecteur.
  • P0 #2 — évasion hôte fermée (compose). Mount docker.sock retiré + security_opt: no-new-privileges + cap_drop: [ALL] dans docker-compose.yml (backup .bak-audit-20260708). Recréé, healthy, run_command OK.

Reste (non fait) : P0 #3 (clés SSH Contabo hors conteneur + pwd sudo), P1 #4 (rédaction audit.log + rotation credentials), run non-root (user:1026:100), token Gitea repo-scopé.

Manche 3 (2026-07-08) — durcissement non-disruptif FAIT et vérifié

  • Rédaction audit.log DÉPLOYÉE. _redact / _SECRET_PATTERNS ajoutés dans linux_mcp_server.py : masquage à l'écriture de user:pass@ (URL), Bearer/token <val>, x-bf-vk, sshpass -p, echo '…' | sudo, <<< "…" vers sudo, curl -u user:pass, NOM…PASS/PWD/SECRET/TOKEN/KEY=…, JSON "password":…. Testé en live (curl -u bob:***, token *** masqués). N'affecte PAS l'exécution (redaction sur la copie journalisée uniquement). → clôt P0 #5 / P1 #4 (volet rédaction).
  • audit.log HISTORIQUE scrubbé à 0. Après scrub : famille 2L2u519w = 0, URLs user:pass@ non masquées = 0, x-bf-vk / Bearer = 0. Méthode : one-off python (mêmes patterns + filet littéral via env SCRUB_LITERAL_RE), remplacement d'inode puis recréation du conteneur pour rouvrir le fd d'écriture. Backup pré-scrub SUPPRIMÉ (il contenait 278 mots de passe en clair). Temp .claude-tmp nettoyés. Fichier en chmod 600 root:root.
  • Token Gitea repo-scopé créé (mcp-nas-deploy, scope write:repository) → hermes-platform/.env sous GITEA_DEPLOY_TOKEN (gitignoré). Vérifié : clone OK, API admin = 403 (bien limité). Remplace le mot de passe bolbol complet dans les URLs de clone/API. → clôt P1 (token scopé).
  • Versionné via API Gitea : linux_mcp_server.py (commit c4f3b2d) + docker-compose.yml (4094366) sur bolbol/linux-mcp-nas main.

🔑 Astuce git/SMB réutilisable (découverte)

git sur le share SMB /Volumes/docker hang (timeouts ~2 min : le .git est sur SMB) et git est absent de l'hôte NAS. → NE PAS faire git push depuis le Mac sur ce share. À la place, pousser via l'API HTTP Gitea contents (aucun git requis, curl vers gitea.bolbol.tn répond vite) :

TOK=$GITEA_DEPLOY_TOKEN   # scope write:repository suffit pour contents API
# 1) récupérer le sha courant du fichier
SHA=$(curl -s -H "Authorization: token $TOK" \
  "https://gitea.bolbol.tn/api/v1/repos/OWNER/REPO/contents/PATH?ref=main" | jq -r .sha)
# 2) PUT le nouveau contenu (base64) avec le sha
curl -s -X PUT -H "Authorization: token $TOK" -H "Content-Type: application/json" \
  "https://gitea.bolbol.tn/api/v1/repos/OWNER/REPO/contents/PATH" \
  -d "{\"message\":\"msg\",\"content\":\"$(base64 -i FICHIER)\",\"sha\":\"$SHA\",\"branch\":\"main\"}"

Marche aussi depuis le conteneur mcp-nas (pas de git dedans). Pour créer un fichier neuf : même PUT sans sha.

Vérif rapide (depuis un poste externe)

# Doit ÉCHOUER (401) — plus de RCE anonyme :
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://mcp-nas.nd.i234.me/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"x","version":"0"}}}'
# Doit MARCHER (200) via le chemin secret (URL du connecteur) :
curl -s -X POST https://mcp-nas.nd.i234.me/<secret>/mcp -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' -d '{...initialize...}'

Manche 4 (2026-07-09) — ROTATION des credentials fuités (piloté service par service)

Le préfixe réutilisé couvrait ~11 familles (pas 5) + fuite dans context-hub/.env (2e magasin central, root:root 600 → invisible au grep en Best0f, lisible via SMB Mac ou sudo) et dans les docs/skills d'agents Hermes. Piège grep réutilisable : un find/grep lancé en Best0f sur le NAS rate les fichiers root-600 ; scanner depuis le Mac (SMB) OU en sudo.

Rotés + vérifiés (services healthy) :

  • Portainer : API PUT /api/users/1 avec champ newPassword (pas password → 400). Auth POST /api/auth.

  • Gitea bolbol : docker exec -u git gitea gitea admin user change-password -u bolbol -p <new> (en 1.21, PAS de flag --must-change-password).

  • n8n / Baserow : compte owner en base (pas de basic-auth env) → changés en UI ; les *_PASSWORD des .env ne sont que des copies de référence à répercuter.

  • NyoraNotes : authenticate() fait un secrets.compare_digest plaintext sur NYORA_PASS_{TT,NYORA,PERSO} (env) ; les agents utilisent le Bearer token, pas le mdp → rotation = MAJ .env + restart.

  • 3 logins web Hermes : var interne HERMES_PASSWORD = ${HERMES_<i>_PASSWORD} via le compose RACINE hermes-platform/docker-compose.yml. Piège : les conteneurs n'ont aucun label composecompose up --force-recreate <workspace> cascade sur l'agent (a mis agent-tt down). Méthode fiable = docker stop <c> && docker rm <c> puis docker compose up -d --no-deps <service> depuis le racine.

  • DSM/sudo Best0f : sudo /usr/syno/sbin/synouser --setpw Best0f <new> (chemin complet requis, sinon command not found). ssh étant par clé, aucun lockout. Mdp hardcodé dans ~23 fichiers (dsm_auth.py liste de spray, ssh_dsm.py, dsm_check_*.py, context-hub/.env, docs) → tous mis à jour.

  • Tokens Gitea : 13 tokens bolbol (plusieurs all/write:admin). GITEA_TOKEN_NYORA fuité (10ccc28dc) roté → token scopé neuf ; 7 tokens révoqués (DELETE /api/v1/users/bolbol/tokens/<id>). Scripts deploy migrés vers GITEA_DEPLOY_TOKEN.

  • Bifrost VKs (Nabil-Key + Yesmine-Key) : rotation dans config.db (UPDATE governance_virtual_keys SET value=… + allow_all_keys=1) + restart bifrost. Piège port : l'API bifrost est en 3085 (8080 interne), 3086 = proxy openresty (renvoie 401 trompeur) → tester sur 3085. Piège scan : la VK vit aussi dans redaction-pro/nginx.conf (.conf raté par un scan .env/.json/.yml) et dans 9 workflows n8n en base (UPDATE workflow_entity SET nodes=replace(nodes::text,old,new)::jsonb sur postgres n8n-DB n8nuser/n8n, puis restart n8n pour recharger le cache). Vérif : nouvelle VK → 200, ancienne → 401 virtual_key_not_found. Yesmine-Key = aucun consommateur infra (clé perso).

  • Scrub : 673 secrets masqués dans 105 fichiers de log (sessions/cron/claude_contexte/data-logs/anciens .env) via sudo python3.

  • SMTP : migration OVH postmaster@bolbol.tnInfomaniak nabil.derouiche@ik.me (mail.infomaniak.com, 465 SSL / 587 STARTTLS) pour baserow + portainer/311. ⚠️ Infomaniak SMTP externe exige un mot de passe d'application (le mdp compte/webmail est rejeté 535). Baserow : EMAIL_SMTP_USE_SSL et USE_TLS mutuellement exclusifs (Django ImproperlyConfigured → crashloop) → n'en garder qu'un.

🚨 INCIDENT — leçon critique : jamais de remplacement texte sur des .db/binaires

Un open(f).read().replace(old,new) en masse sur hermes-platform a corrompu les 3 state.db SQLite des agents (décalage d'octets) → perte de l'historique conversation (tables sessions/messages), aucun backup. Savoir des agents intact (fichiers). Récup : stop agent → mv state.db* → start (recrée vierge) → supprimer le lock gateway-locks/telegram-bot-token-*.lock sinon le bot Telegram ne poll plus. RÈGLE : tout script de scrub/remplacement DOIT exclure *.db*/binaires et se scoper aux extensions texte.

Nouvelles valeurs : PAS dans ce runbook — remises à Nabil (Bitwarden). Reste : SMTP OVH (externe), SSH_PASS_NYORA, re-scoper token nyora-notes-claude-code, nettoyer docs/skills restants, run non-root.