Anti-patterns
Cette page documente les erreurs critiques rencontrées (ou anticipées) sur Le Labo du Yeti. Chaque entrée explique pourquoi le pattern est dangereux, ce qu'il casse, et la solution canonique. Considère ce document comme une checklist défensive : si une de ces situations apparaît dans une PR, refuse le merge.
1. Manipulation directe des données
1.1 Ne pas modifier content/cms.db à la main
La base SQLite est gérée exclusivement par backend/src/utils/db-store.js et les services métier (blog-service.js, cocon-service.js, pages-service.js…). Ouvrir cms.db avec DB Browser ou exécuter un UPDATE brut court-circuite :
- Le mode WAL et le checkpoint requis avant tout snapshot.
- L'optimistic locking sur les pages (
if_updated_at→ 409 en cas de conflit). - Les logs append-only qui tracent chaque mutation.
- Les FK ON DELETE CASCADE entre
users,api_tokens,extensions,extension_settings,extension_data.
De la même façon, les fichiers *.json.bak dans content/ sont des reliquats de la migration KV legacy. Ne les édite pas et ne les restaure pas : passe par POST /api/backup/restore qui fait un safety backup auto avant écrasement.
cms.db pendant que le serveur tourne en mode WAL peut produire une base corrompue ou un état où le WAL contient des écritures invisibles. Toujours closeDb() puis checkpointDb() avant tout export, et arrêter Node avant tout import.1.2 Ne plus créer de nouveaux fichiers JSON de stockage
La règle de stockage est unique et stricte :
| Type de donnée | Stockage | Helpers |
|---|---|---|
| Config rare (auth, global, ai, blog config…) | Table stores (KV) | readStore / writeStore / updateStore |
| Données chaudes (users, pages, articles, cocon, logs, tokens, AI usage) | Tables SQL dédiées | Helpers spécialisés (usersInsert, logsInsert, aiUsageInsert…) |
| Données par extension | extension_data KV scopée | ext.storage.get/set/keys/delete |
Inventer un nouveau content/mon-truc.json casse les backups (le ZIP n'embarque que cms.db + uploads/ + extensions/installed/), brise la cohérence transactionnelle et empêche l'observabilité via les logs SQL.
2. Sécurité — champs sensibles, injections, SSRF
2.1 Ne jamais exposer les champs sensibles dans une réponse API
La liste des champs strictement interdits en sortie :
users.password_hashusers.email— sauf endpoints réservés àowner/adminapi_tokens.token_hash- SMTP :
smtp.pass,smtp.user - IA :
ai.api_key, clés Unsplash/Pexels/Pixabay/Brave/SerpAPI/Tavily JWT_SECRETet toute variable d'environnement Plesk
Le contexte injecté aux extensions (ext.cms.*) est déjà filtré. Si tu ajoutes un nouveau helper exposé aux extensions, fais un pass de relecture sur le serializer pour confirmer qu'aucun champ sensible ne fuit. Les 24 permissions documentées dans extensions.html sont là pour ça.
res.json(user) qui renvoie l'objet brut issu de usersFindById(id, { includeSecret: true }). Toujours passer par un mapper qui whitelist les champs publics.2.2 Ne jamais utiliser shell: true avec un input utilisateur
Le CMS exécute des commandes système dans deux endroits sensibles : l'installation d'extensions (npm install) et le builder de site. Toute exécution doit suivre :
// MAUVAIS — command injection trivial
const { exec } = require('child_process');
exec(`npm install ${userPackage}`, { shell: true });
// BON — args en array, pas de shell, path absolu
const { spawn } = require('child_process');
const npmCmd = resolveNpmCommand(); // résout via sibling de node sur Plesk
spawn(npmCmd, ['install', '--no-audit', '--no-fund'], {
cwd: extensionDir,
shell: false
});
2.3 Ne pas extraire un ZIP sans assertZipEntriesSafe
Deux flux acceptent un ZIP de l'utilisateur : POST /api/extensions et POST /api/backup/restore. Sans guard, un attaquant peut placer une entry nommée ../../etc/passwd ou ..\\Windows\\System32\\… (zip slip). Le helper assertZipEntriesSafe(entries, targetDir) :
- Refuse toute entry contenant
..ou un chemin absolu. - Vérifie que la résolution finale reste dans
targetDir. - Limite la taille décompressée totale (anti zip-bomb).
2.4 Ne pas fetch() une URL sans assertUrlSafe
Plusieurs flux fetchent des URLs externes : import de médias, banques d'images (Unsplash/Pexels/Pixabay), recherche web (Brave/SerpAPI/Tavily), webhooks d'extension. Toute URL fournie par un utilisateur ou par une extension doit passer par assertUrlSafe(url) qui :
- Refuse
file://,ftp://, autres schémas non-HTTP(S). - Refuse
localhost,127.0.0.0/8,169.254.0.0/16(link-local AWS metadata),10.0.0.0/8,192.168.0.0/16,::1, etc. - Résout le DNS et revérifie l'IP finale (anti DNS rebinding).
3. Déploiement Plesk
3.1 Ne pas écraser les dossiers utilisateur lors d'un déploiement
Le déploiement Plesk pousse uniquement les sources backend + frontend/dist. Les chemins suivants sont la propriété du client et ne doivent jamais être déplacés, ré-initialisés ou git-pull-écrasés :
| Chemin | Contenu | Risque si écrasé |
|---|---|---|
content/ | cms.db + WAL | Perte totale de toutes les données |
uploads/ | Médias uploadés (images, vidéos, PDF) | Pages cassées, médiathèque vide |
public/ | Site statique buildé | Site hors-ligne jusqu'au prochain build |
brouillons/ | Templates en cours | Travail en cours perdu |
extensions/installed/ | Extensions tiers installées | Plugins désactivés, données extension_data orphelines |
Le .gitignore exclut déjà ces chemins. Vérifie qu'aucun nouveau script de release ne contourne cette exclusion par un rsync --delete mal calibré.
main aveugle sur Plesk. Le workflow correct est : tag Git → build CI → push branche release → déploiement Plesk qui ne touche QUE les sources.3.2 Ne pas appeler npm install sans path absolu sur Plesk
Plesk fournit Node via un installeur qui ne place pas toujours npm dans le PATH du process Node de l'application. Le helper resolveNpmCommand() trouve npm comme sibling du binaire node en cours d'exécution :
// Sur Plesk : /opt/plesk/node/22/bin/node
// → npm est à : /opt/plesk/node/22/bin/npm
const npmPath = path.join(path.dirname(process.execPath), process.platform === 'win32' ? 'npm.cmd' : 'npm');
Référence : commit bbe809e Fix(extensions): résoudre npm via sibling de node (Plesk-safe).
3.3 Ne pas oublier le WAL checkpoint avant copie de cms.db
SQLite en mode WAL maintient les écritures récentes dans cms.db-wal. Copier cms.db seule donne un snapshot incohérent (les dernières écritures sont absentes). Toujours :
await checkpointDb(); // PRAGMA wal_checkpoint(TRUNCATE)
// puis copier cms.db
// (optionnel : closeDb() si on doit déplacer/remplacer le fichier)
Le helper checkpointDb() est appelé automatiquement par GET /api/backup/export ; n'écris pas un script de backup parallèle qui l'oublie.
4. CSP, sécurité front et confusion admin/public
4.1 La CSP est sur /admin uniquement
Helmet et la Content Security Policy stricte (script-src 'self', pas d'inline) ne protègent que l'interface d'administration React. Le site public servi depuis public/ n'a pas cette CSP, parce qu'il doit pouvoir embarquer :
- Le runtime
/api/forms/runtime.js - Des snippets injectés par extensions (SureFeedback, analytics…)
- Du JS inline dans les templates utilisateur ou les pages cocon
Conséquence : ne pas compter sur la CSP pour bloquer un script inline malicieux sur le site public. Le seul rempart est l'assainissement à l'écriture dans les pages/articles/cocon, et la signature des extensions au moment de l'install.
4.2 Le frontend cache l'UI, le backend fait foi
Le frontend lit GET /api/capabilities pour filtrer la sidebar et masquer les boutons interdits. C'est de l'UX, pas de la sécurité. Toute action côté serveur doit :
- Passer
requireAuth - Passer
requireRole(...)si applicable - Passer
requireCapability(featureId)si la feature est dansCORE_FEATURES
Si tu ajoutes un endpoint sans requireCapability alors que la feature existe, un editor peut potentiellement appeler la route directement même si le bouton est caché dans la sidebar.
owner bypasse requireCapability() dès la première ligne du middleware (if (role === 'owner') return next()). Ne tente pas de "désactiver une feature pour owner" via la matrice : c'est volontairement impossible.5. RBAC — changements de rôle et règles métier
5.1 Ne jamais changer un rôle hors de updateAdmin(id, patch, caller)
La règle "seul owner peut changer un rôle" est implémentée dans le service auth-service.js, pas dans la route. Si une nouvelle route de bulk-edit appelle directement usersUpdate(id, { role }), la règle saute. Toutes les mutations de rôle DOIVENT passer par :
await updateAdmin(targetId, { role: 'admin' }, callerUser);
// Le service vérifie :
// - callerUser.role === 'owner'
// - on ne dégrade pas le dernier owner
// - on ne crée pas un orphelin de propriété
5.2 Ne pas implémenter une règle de capability uniquement côté front
Exemple typique : "l'editor ne peut voir que son propre compte dans Réglages → Comptes". La règle doit exister dans GET /api/auth/admins côté backend (filtre par req.user.id si rôle editor) et côté front (masquer les autres lignes). Front-only = trivialement contournable via cURL.
6. Cohérence UI — settings primitives
6.1 Ne pas écrire de HTML brut pour des composants Settings
Tous les écrans /settings/* doivent utiliser frontend/src/components/settings-primitives.jsx :
SettingsSection— cadre avec titre et descriptionSettingsField— label + input + hint + errorSettingsToggle,SettingsSelect,SettingsTextarea…SettingsActions— boutons Sauvegarder/Annuler avec état dirty
Court-circuiter ces primitives produit des écrans qui désynchronisent visuellement à la moindre mise à jour des tokens CSS (frontend/src/styles/tokens.css), et casse le mode sombre.
7. Pièges IA
7.1 Ne pas tronquer les générations IA avec un max_tokens trop bas
Anthropic Claude requiert au minimum 8192 tokens de sortie pour générer un article de blog complet ou une page cocon riche. Un max_tokens: 4096 produit des HTML coupés en plein <section>, ce qui :
- Casse le rendu (balises non fermées).
- Fait perdre le JSON-LD final placé en fin de document.
- Consomme quand même les tokens d'entrée du prompt système (gros).
Configure systématiquement max_tokens: 8192 minimum dans ai-service.js pour les flux articles/cocon. Référence en cas de doute : la table ai_usage permet d'auditer output_tokens par kind et action via aiUsageRecent().
7.2 Ne pas oublier aiGenLimiter sur un nouveau flux IA
Tous les endpoints qui déclenchent un appel Anthropic (génération cocon, génération article, regénération image) sont rate-limités à 10/min par IP via aiGenLimiter. Un nouveau flux qui l'oublie peut être abusé pour épuiser le budget API du client.
8. Git — secrets et mémoire
8.1 Ne jamais committer .env, MEMORY.md, cms.db
Le .gitignore protège déjà ces fichiers, mais reste vigilant lors d'opérations git add -A ou git add . :
.envcontient potentiellement des clés de dev (à différencier de Plesk qui injecte ses propres env vars).MEMORY.mdest la mémoire long-terme de l'agent Claude Code, contient des décisions stratégiques privées du projet.cms.db,cms.db-wal,cms.db-shmsont les données utilisateur live.
Préfère git add <fichiers spécifiques> à git add .. Si un secret est committé par erreur, considère-le compromis : rotation immédiate de la clé concernée, jamais un simple git reset.
9. Checklist de revue de PR
Avant de merger, balayer cette liste :
- Aucun nouveau fichier
*.jsondanscontent/ou ailleurs comme stockage métier. - Aucune route qui renvoie
password_hash,token_hash,api_key,smtp.pass,JWT_SECRET. - Tout
exec()/spawn()sur input utilisateur utiliseshell: falseet args en array. - Tout
unzippasse parassertZipEntriesSafe. - Tout
fetch()sur URL externe utilisateur passe parassertUrlSafe. - Toute mutation de
users.rolepasse parupdateAdmin(id, patch, caller). - Tout endpoint sensible a
requireAuth+requireRole+requireCapability. - Toute génération IA d'article ou cocon a
max_tokens ≥ 8192et leaiGenLimiter. - Aucun composant
/settings/*n'utilise de HTML brut horssettings-primitives.jsx. - Aucun script de release ne touche
content/,uploads/,brouillons/,public/,extensions/installed/.