Sécurité

Le Labo du Yeti adopte une posture defense-in-depth : aucune mesure unique n'assure la sécurité, mais l'empilement de plusieurs couches (authentification forte, contrôle d'accès granulaire, durcissement HTTP, filtrage des entrées, isolation des dépendances tierces, audit append-only) réduit considérablement la surface d'attaque. Cette page documente l'état actuel du hardening, le score sécurité (92/100 après corrections), les choix techniques effectués, et les bonnes pratiques opérationnelles à suivre côté exploitation Plesk.

Score actuel : 92/100. Les 8 points restants concernent des éléments non bloquants (rotation automatisée du JWT_SECRET, alertes proactives sur ai_usage, audit externe). Aucune vulnérabilité npm audit connue à ce jour.

Modèle de menaces

Le CMS est une application SaaS hybride exposée publiquement (un domaine Plesk par client). Les principales menaces considérées :

Authentification

Hachage des mots de passe

Les mots de passe sont hachés avec bcrypt (rounds = 10) avant insertion dans la table users.password_hash. Aucun mot de passe en clair n'est jamais stocké ni journalisé. Le hash est exclu par défaut des SELECT — il faut explicitement passer { includeSecret: true } à usersFindByLogin() (cf. Base de données).

// backend/src/services/auth-service.js
const hash = await bcrypt.hash(password, 10);
await usersInsert({ id, fullname, username, email, password_hash: hash, role });

Double authentification (2FA email)

Lorsqu'elle est activée pour un compte, la 2FA envoie un code à 6 chiffres par email après validation du mot de passe. Le code expire au bout de 10 minutes et n'autorise qu'une seule tentative de vérification réussie. Endpoint : POST /api/auth/2fa/verify (rate-limité à 10/min).

Politique de mot de passe

Imposée à la création et à la réinitialisation :

Réinitialisation 3-step

Le flux de réinitialisation passe par trois endpoints distincts (chacun rate-limité 10/min) : POST /password-reset/request envoie un token par email, POST /password-reset/verify valide le token, POST /password-reset/confirm applique le nouveau mot de passe. Le token est à usage unique et expire rapidement.

Contrôle d'accès (RBAC + Capabilities)

Le contrôle d'accès se fait sur deux niveaux combinables :

NiveauMiddlewareRôle
1 — RôlerequireRole('owner', 'admin', ...)Baseline grossière : seul un sous-ensemble de rôles peut atteindre l'endpoint.
2 — CapabilityrequireCapability('blog')Matrice fine Rôle × Feature, modifiable en runtime par l'owner.

L'owner bypasse toujours requireCapability via un court-circuit explicite (if (role === 'owner') return next()). Voir Authentification & RBAC pour la matrice complète.

Owner protégé. Il est impossible de supprimer ou de rétrograder le dernier owner du système : le contrôleur compte les owners actifs avant toute mutation et renvoie 409 Conflict si l'opération laisserait le CMS orphelin.

API tokens (machine-to-machine)

Les tokens API utilisés pour le reporting, les intégrations CI/CD ou les serveurs MCP suivent le format cmstok_*. Le token brut est affiché une seule fois à la création — seul son hash SHA-256 est stocké dans api_tokens.token_hash (colonne UNIQUE).

JWT & secrets

Les sessions admin reposent sur des JWT signés avec JWT_SECRET, lu depuis les variables d'environnement Plesk (jamais depuis .env en prod).

Anti-pattern. Ne jamais commiter JWT_SECRET dans .env ni dans le dépôt Git. Stocker uniquement dans les Variables d'environnement Node de Plesk.

Helmet & CSP

Helmet est activé globalement pour appliquer les en-têtes de sécurité standard (X-Frame-Options, X-Content-Type-Options, Strict-Transport-Security, Referrer-Policy, etc.).

CSP scopée sur /admin uniquement. Une Content Security Policy stricte est appliquée aux routes admin React, mais pas aux sites publics buildés. Ce choix est volontaire : les sites clients embarquent fréquemment Google Analytics, Stripe, Tag Manager, widgets sociaux, etc., qui injectent du JS inline incompatible avec une CSP restrictive. Imposer une CSP globale casserait les sites en production.

CORS

EnvironnementPolitiqueVariable
DéveloppementOuvert (*)
ProductionWhitelist stricteCORS_ORIGIN (domaine exact, sans wildcard)

Le serveur refuse de démarrer si CORS_ORIGIN n'est pas défini en production. Si plusieurs origines doivent être autorisées (preview + prod), les passer séparées par virgule.

Rate limiting

Tous les endpoints sensibles sont protégés par express-rate-limit avec des seuils calibrés selon le coût et le risque d'abus.

LimiteurScopeLimiteJustification
globalToute l'API600 req / minGarde-fou anti-DoS basique.
loginLimiter/auth/login, /2fa, /password-reset/*10 req / minAnti brute-force credentials.
aiGenLimiter/cocon/generate, /blog/articles/:id/generate10 req / minCoût Anthropic élevé, latence forte.
aiCallLimiter/ai/usage/export.csv10 req / minAgrégation lourde côté DB.
restoreLimiter/backup/restore1 req / minOpération destructive et atomique.
submitLimiter/forms/:formId10 req / minAnti-spam formulaires publics.

Anti ZIP slip

Le ZIP slip est une vulnérabilité classique où un attaquant crée une archive contenant des chemins relatifs malicieux (../../etc/passwd) pour écrire en dehors du dossier cible lors de l'extraction.

Deux points d'entrée acceptent des archives ZIP :

Tous deux passent par le helper centralisé backend/src/utils/zip-safe.js qui :

  1. Résout chaque entrée du ZIP avec path.resolve.
  2. Vérifie que le chemin résolu commence bien par le dossier cible (resolved.startsWith(target + path.sep)).
  3. Rejette l'archive entière en cas de la moindre entrée hors scope (fail-closed).
  4. Filtre les liens symboliques.

Implémentation aussi appelée depuis backend/src/services/backup-service.js et backend/src/controllers/extensions-controller.js.

Anti-SSRF

Le CMS effectue plusieurs requêtes HTTP sortantes vers des URLs fournies par l'utilisateur ou l'IA (vérification d'une URL, téléchargement d'une featured image depuis Unsplash/Pexels, etc.). Sans précaution, un attaquant pourrait pointer ces fetch vers http://localhost:8080/internal-api ou http://169.254.169.254/latest/meta-data/ (métadonnées AWS/GCP) pour exfiltrer des secrets infrastructure.

Le helper backend/src/utils/ssrf-guard.js est invoqué avant toute requête sortante par verifyUrl() et downloadFeaturedLocally(). Il bloque :

DNS rebinding. La résolution DNS est effectuée à la volée puis comparée à la liste noire avant le fetch. Pour les flux à haut risque, envisager un second resolve juste avant la connexion TCP (TOCTOU).

Sanitization des sorties API

Le fichier backend/src/extensions/extension-cms-api.js sert de pare-feu entre les extensions tierces et la base de données. Il expose un contexte ext.cms.* en lecture seule où les champs sensibles sont systématiquement supprimés avant retour :

ChampTableRaison du filtrage
emailusersDonnée personnelle (RGPD).
password_hashusersPermettrait offline bruteforce.
token_hashapi_tokensRecouvrement de session.
token (plaintext)Jamais conservé, donc jamais exposé.
smtp.passwordglobalSpoofing email.
ai.api_keyglobalVol crédit Anthropic.
images.api_keys.*globalVol crédit Unsplash/Pexels/Pixabay.
JWT_SECRETenvForge de session.

Résolution npm Plesk-safe

L'installation automatique des dépendances d'une extension (npm install sur extensions/installed/<id>/package.json) doit fonctionner sur Plesk, où le binaire npm n'est pas toujours dans le PATH du processus Node. Le loader d'extensions tente, dans l'ordre :

  1. npm dans le PATH (mode dev classique).
  2. Sibling de process.execPath : node et npm sont généralement installés dans le même dossier sur Plesk.
  3. Fallback npm-cli.js exécuté via node ./npm-cli.js.

Cette logique évite l'erreur fréquente npm: command not found en production. Voir le commit récent Fix(extensions): résoudre npm via sibling de node (Plesk-safe).

Audit trail

La table logs est append-only (aucun UPDATE ni DELETE émis par le code). Elle est indexée sur timestamp DESC, type, action, user_id pour des requêtes rapides côté UI (Outils → Logs).

Évènements journalisés :

Le champ meta stocke un JSON sérialisé (IP, user-agent, contexte) pour reconstituer un évènement sans dépendre d'une corrélation externe.

Sécurité des dépendances

L'audit npm audit remonte actuellement 0 vulnérabilité (post npm audit fix). Le projet maintient cet état grâce à :

Backup & sauvegarde

Le service de backup (backend/src/services/backup-service.js) implémente plusieurs garde-fous :

Bonnes pratiques opérationnelles

Pour maintenir et améliorer le score sécurité côté exploitation :

  1. Rotation du JWT_SECRET annuelle minimum. Au changement, toutes les sessions actives sont invalidées — prévenir les administrateurs.
  2. Audit des logs réguliers : revue hebdomadaire de logs filtré sur type = 'auth.failed' et type = 'extension.installed' pour repérer les anomalies.
  3. Restreindre CORS_ORIGIN en prod au domaine exact, sans wildcard, sans *.
  4. Désactiver les extensions inconnues ou non maintenues — chaque extension élargit la surface d'attaque (cf. Système d'extensions).
  5. Monitorer ai_usage pour détecter un abus (pic anormal de input_tokens sur un compte donné = compte potentiellement compromis).
  6. Mots de passe forts pour les admins : encourager l'usage d'un gestionnaire (Bitwarden, 1Password).
  7. Activer la 2FA sur tous les comptes owner et admin.
  8. Maintenir Node 22 LTS : suivre les releases sécurité et appliquer les patches mensuels.
  9. Vérifier les permissions des extensions avant install via POST /api/extensions/inspect — refuser celles demandant des capabilities disproportionnées.
Cf. aussi. Authentification & RBAC pour la matrice détaillée, Déploiement pour la configuration Plesk, Anti-patterns pour les erreurs à ne pas reproduire, et Système d'extensions pour le modèle de permissions.

Checklist mise en production

ItemStatut attendu
JWT_SECRET défini dans Plesk (≥24 c, unique au client)OBLIGATOIRE
CORS_ORIGIN = domaine exactOBLIGATOIRE
NODE_ENV=productionOBLIGATOIRE
SMTP configuré (sinon 2FA et reset password KO)OBLIGATOIRE
HTTPS forcé via Plesk + HSTS via HelmetOBLIGATOIRE
Document root Plesk = public/OBLIGATOIRE
Backup hebdomadaire automatisé (cron Plesk)RECOMMANDÉ
2FA activée sur tous les ownersRECOMMANDÉ
npm audit = 0 vulnérabilitéRECOMMANDÉ