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.
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 :
- Compromission de compte : brute force login, vol de session, réutilisation de mot de passe.
- Élévation de privilèges : un
editorqui tente d'accéder à des endpoints owner. - Injection : SQL (mitigée par
better-sqlite3en prepared statements), XSS dans les templates HTML, SSRF via fetch d'URL externes. - Archive malveillante : ZIP slip lors de l'import d'une extension ou la restauration d'un backup.
- Exfiltration de secrets : extensions tierces qui lisent
password_hash,token_hash, clés IA, SMTP, etc. - Abus de quota IA : compte compromis qui génère des milliers de pages pour épuiser le crédit Anthropic.
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 :
- Longueur minimale : 12 caractères.
- 4 classes requises : minuscule, majuscule, chiffre, caractère spécial.
- Vérification côté backend (la validation frontend est purement informative).
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 :
| Niveau | Middleware | Rôle |
|---|---|---|
| 1 — Rôle | requireRole('owner', 'admin', ...) | Baseline grossière : seul un sous-ensemble de rôles peut atteindre l'endpoint. |
| 2 — Capability | requireCapability('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.
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).
- Authentification via header
Authorization: Bearer cmstok_... - Révocation soft (
revoked_at) — pas de hard delete pour conserver la traçabilité. - Expiration optionnelle (
expires_at). - Champ
last_used_atmis à jour à chaque requête authentifiée.
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).
- Longueur minimale 24 caractères, vérifiée au boot en mode production (le serveur refuse de démarrer sinon).
JWT_EXPIRES_INcontrôle la durée de vie (par défaut courte, renouvelée à chaque requête authentifiée).- Un secret par client : ne jamais partager
JWT_SECRETentre instances SaaS.
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.).
CORS
| Environnement | Politique | Variable |
|---|---|---|
| Développement | Ouvert (*) | — |
| Production | Whitelist stricte | CORS_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.
| Limiteur | Scope | Limite | Justification |
|---|---|---|---|
global | Toute l'API | 600 req / min | Garde-fou anti-DoS basique. |
loginLimiter | /auth/login, /2fa, /password-reset/* | 10 req / min | Anti brute-force credentials. |
aiGenLimiter | /cocon/generate, /blog/articles/:id/generate | 10 req / min | Coût Anthropic élevé, latence forte. |
aiCallLimiter | /ai/usage/export.csv | 10 req / min | Agrégation lourde côté DB. |
restoreLimiter | /backup/restore | 1 req / min | Opération destructive et atomique. |
submitLimiter | /forms/:formId | 10 req / min | Anti-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 :
POST /api/extensions— installation d'une extension tierce (10 MB max).POST /api/backup/restore— restauration d'un snapshot complet (200 MB max).
Tous deux passent par le helper centralisé backend/src/utils/zip-safe.js qui :
- Résout chaque entrée du ZIP avec
path.resolve. - Vérifie que le chemin résolu commence bien par le dossier cible (
resolved.startsWith(target + path.sep)). - Rejette l'archive entière en cas de la moindre entrée hors scope (fail-closed).
- 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 :
- Les schémas autres que
http/https(rejettefile://,gopher://, etc.). - Les noms d'hôte résolvant vers des plages IP privées (RFC 1918) :
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16. - Le loopback
127.0.0.0/8et::1. - Le link-local
169.254.0.0/16(en particulier 169.254.169.254 — métadata cloud AWS / GCP / Azure). - Les broadcast / multicast / réservés.
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 :
| Champ | Table | Raison du filtrage |
|---|---|---|
email | users | Donnée personnelle (RGPD). |
password_hash | users | Permettrait offline bruteforce. |
token_hash | api_tokens | Recouvrement de session. |
token (plaintext) | — | Jamais conservé, donc jamais exposé. |
smtp.password | global | Spoofing email. |
ai.api_key | global | Vol crédit Anthropic. |
images.api_keys.* | global | Vol crédit Unsplash/Pexels/Pixabay. |
JWT_SECRET | env | Forge 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 :
npmdans lePATH(mode dev classique).- Sibling de
process.execPath:nodeetnpmsont généralement installés dans le même dossier sur Plesk. - Fallback
npm-cli.jsexécuté vianode ./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 :
- Login / logout / 2FA / password reset.
- Création / modification / suppression d'utilisateur ou de rôle.
- Création / révocation d'API token.
- Modification de la matrice
capabilities. - Install / activation / désactivation / désinstallation d'extension.
- Export / restauration de backup.
- Modification des paramètres de sécurité.
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 à :
- Verrouillage strict via
package-lock.json(committé). - Dépendances minimales et auditées (pas de méga-frameworks côté backend).
better-sqlite3en prepared statements partout (immunité SQL injection native).- Pas de
eval(), pas deFunction(), pas devm.runInThisContext()exposé aux extensions (sandbox via require + permissions).
Backup & sauvegarde
Le service de backup (backend/src/services/backup-service.js) implémente plusieurs garde-fous :
- Safety pre-restore automatique : avant toute restauration, un snapshot complet de l'état courant est créé localement. Si la restauration échoue ou produit un état incohérent, l'owner peut revenir en arrière.
- Purge 30 jours : les safety backups de plus de 30 jours sont supprimés automatiquement pour ne pas saturer le disque.
- Checkpoint WAL avant export pour garantir un fichier
cms.dbcohérent (cf. Base de données). - Restauration réservée à l'
owner+ capabilitysettings_security+ rate limit 1/min.
Bonnes pratiques opérationnelles
Pour maintenir et améliorer le score sécurité côté exploitation :
- Rotation du
JWT_SECRETannuelle minimum. Au changement, toutes les sessions actives sont invalidées — prévenir les administrateurs. - Audit des logs réguliers : revue hebdomadaire de
logsfiltré surtype = 'auth.failed'ettype = 'extension.installed'pour repérer les anomalies. - Restreindre
CORS_ORIGINen prod au domaine exact, sans wildcard, sans*. - Désactiver les extensions inconnues ou non maintenues — chaque extension élargit la surface d'attaque (cf. Système d'extensions).
- Monitorer
ai_usagepour détecter un abus (pic anormal deinput_tokenssur un compte donné = compte potentiellement compromis). - Mots de passe forts pour les admins : encourager l'usage d'un gestionnaire (Bitwarden, 1Password).
- Activer la 2FA sur tous les comptes
owneretadmin. - Maintenir Node 22 LTS : suivre les releases sécurité et appliquer les patches mensuels.
- Vérifier les permissions des extensions avant install via
POST /api/extensions/inspect— refuser celles demandant des capabilities disproportionnées.
Checklist mise en production
| Item | Statut attendu |
|---|---|
JWT_SECRET défini dans Plesk (≥24 c, unique au client) | OBLIGATOIRE |
CORS_ORIGIN = domaine exact | OBLIGATOIRE |
NODE_ENV=production | OBLIGATOIRE |
| SMTP configuré (sinon 2FA et reset password KO) | OBLIGATOIRE |
| HTTPS forcé via Plesk + HSTS via Helmet | OBLIGATOIRE |
Document root Plesk = public/ | OBLIGATOIRE |
| Backup hebdomadaire automatisé (cron Plesk) | RECOMMANDÉ |
| 2FA activée sur tous les owners | RECOMMANDÉ |
npm audit = 0 vulnérabilité | RECOMMANDÉ |