Aller au contenu
Mocky/Docs v0.2
Référence

AUDIT 2026 07

85 min de lecture

Plan d'action Mocky — audit multi-agents, synthèse et feuille de route

Pourquoi c’est ainsi

Un audit qui se contente d'énumérer des défauts ne se transforme jamais en travail : il faut savoir par quoi commencer. Le document est donc organisé en lots — un lot = un thème, un effort chiffré, un risque de régression annoncé — parce que c'est l'unité qu'on peut livrer, tester, et abandonner en bloc si elle tourne mal. Chaque constat porte son chemin fichier:ligne pour que la vérification ne dépende pas de la confiance accordée au rapport.

Base : 5 audits de dimension + vérification adversariale des failles de sécurité. Tous les constats ci-dessous ont été relus dans le code (chemins et lignes vérifiés). Aucun fichier n'a été modifié.


0. Verdict en une page#

Pourquoi c’est ainsi

Un rapport de soixante constats devient illisible si tout y paraît également grave. Cette page tranche d'abord ce qui fonctionne, puis nomme les trois problèmes qui commandent tous les autres, et range explicitement le reste en « cosmétique » : la hiérarchie est le livrable, pas la liste. Elle commence par les réussites parce qu'un audit qui n'énumère que des défauts fait perdre la mesure de ce qu'il ne faut surtout pas casser en les corrigeant.

Mocky fait ce qu'il promet : canvas infini réel, streaming de génération, mode Interact, liens + Demo, 17 presets, annotations par snip, export ZIP Vite runnable, comptes + SSO, pipeline Muse serveur complet. Le build est vert (tsc --noEmit = 0, 398 tests en 3,3 s), le typage est rigoureux (strict + noUnused*, zéro @ts-ignore), et le socle d'authentification est correct (scrypt + sel, timingSafeEqual, jetons 32 octets, HS256 vérifié en temps constant avec iss/aud/exp/anti-rejeu jti).

Les trois vrais problèmes ne sont pas là où on les attend :

#ProblèmePourquoi c'est le vrai sujet
1La couche de persistance perd du travailsrc/lib/sync.ts (86 lignes, zéro test) et src/lib/project.ts contiennent 4 chemins reproductibles de perte de projets. C'est toi l'utilisateur : c'est le risque n°1 pour toi personnellement, avant toute question de sécurité.
2Toute la surface coûteuse du backend est ouverte sans authentification/__provider, /api/images/*, /api/muse/dossier, /api/text/vision : 4 montages sans garde. Depuis que l'admin peut poser une clé LLM côté serveur, l'instance est un proxy LLM + générateur d'images gratuit. Gravité conditionnée à l'exposition réseau (voir §2).
3Il n'existe aucun système de designtailwind.config.js → theme: { extend: {} } (vérifié : vide). Les 3 thèmes sont 96 surcharges manuelles d'utilitaires Tailwind dans src/index.css:77-203, alors que les composants utilisent 109 classes de couleur distinctes en 595 occurrences. ~76 % de la surface colorée n'est pas thémée. Aucune refonte n'est possible sans supprimer d'abord ce bloc.

Et un quatrième, produit : le dossier Muse n'est jamais persisté. La fonctionnalité mise en avant #2 du README est mono-coup — dès la première retouche d'un écran, la direction artistique disparaît.

Ce qui est cosmétique et que j'assume comme tel (à ne pas prioriser) : public/ copié inutilement dans l'image Docker, react/react-dom en dependencies, l'attribut allow="" manquant sur l'iframe (non exploitable en l'état, vérifié), la politique de mot de passe scrypt, les en-têtes HSTS/CSP côté document principal. Ce sont des lignes de finition, pas des lots.


1. LOT 0 — Arrêter la perte de travail (BUG — priorité absolue)#

Pourquoi c’est ainsi

Les deux lots marqués « RISQUE » qui suivent supposent un tiers : il faut qu'un attaquant, un réseau ou un navigateur s'y prête pour que le dommage arrive. Celui-ci décrit une perte déjà en cours, subie par la personne qui utilise l'outil sans que personne d'autre y soit pour quelque chose, et une perte de données est le seul dommage qu'aucun correctif ultérieur ne rattrape. D'où la priorité absolue, indépendante de toute question d'exposition réseau.

C'est le seul lot où tu es la victime, pas un attaquant hypothétique. Quatre chemins indépendants, tous dans 450 lignes non testées.

0.1 reconcileOnLogin écrase le local par le serveur sans comparer les dates (critique)#

Pourquoi c’est ainsi

Dès qu'une même donnée existe en deux exemplaires — le navigateur et le serveur — la réconciliation a besoin d'un critère pour départager ; sans critère, elle devine. La règle implicite est ici « le serveur gagne », ce qui détruit la copie locale chaque fois que c'est elle la plus récente, c'est-à-dire chaque fois qu'un onglet s'est fermé avant que l'envoi différé ne parte. L'horodatage nécessaire à l'arbitrage est pourtant déjà écrit par le serveur : c'est le type TypeScript du client qui ne le déclare pas, donc le champ est jeté en silence à la lecture.

src/lib/sync.ts:72-86 — vérifié : hasServerProjects est vrai dès que le blob serveur n'est ni null ni '[]', puis localStorage.setItem(PROJECTS_KEY, server.projects) sans aucun arbitrage. Le champ Project.updatedAt existe (src/lib/project.ts:52), le serveur écrit déjà updatedAt: Date.now() (server/index.js:608), mais interface ServerData (src/lib/api.ts:24-27) ne le déclare même pas — il est jeté par le client.

Scénario reproductible : je génère un écran, je ferme l'onglet dans la seconde. Le flush localStorage passe (project.ts:89, beforeunload), le push serveur de 800 ms (sync.ts:38) non. Je rouvre : App.tsx:49 appelle reconcileOnLogin, le serveur renvoie le blob d'avant, localStorage est écrasé, changed est vrai, App.tsx:50 fait window.location.reload(). L'écran est perdu, sans un mot.

Correctif (src/lib/api.ts, src/lib/sync.ts, server/index.js:598-602) :

  1. Ajouter updatedAt?: number à ServerData et ne plus le jeter côté serveur.
  2. Dans reconcileOnLogin, calculer localMax = max(p.updatedAt) et n'adopter le serveur que s'il est strictement plus récent.
  3. En cas de divergence des deux côtés, fusionner par project.id en gardant le plus récent — ne jamais supprimer un projet local absent du serveur.
  4. navigator.sendBeacon('/api/data', blob) dans le beforeunload de project.ts:89 pour fermer la fenêtre de 800 ms.

0.2 pushNow avale les écritures faites pendant un push en vol (critique)#

Pourquoi c’est ainsi

Fusionner plusieurs demandes d'envoi en une seule est correct : inutile d'écrire cinq fois le même contenu. Mais la fusion n'est sûre que si l'envoi relit les données au moment où il part ; ici la lecture a lieu une seule fois, avant la boucle de reprise, donc tout ce qui est écrit pendant les quinze secondes de reprises se retrouve hors de l'instantané envoyé. Rien ne mémorise « il s'est passé quelque chose entre-temps », et c'est exactement la seule information qui manquerait pour relancer un envoi.

src/lib/sync.ts:36 — vérifié : if (pending) return pending // already in flight — coalesce. Et doPushWithRetry (sync.ts:44-45) lit localStorage une seule fois, avant la boucle de retry, puis retente 5 fois avec backoff 1+2+4+8 s ≈ 15 s. Pendant cette fenêtre, chaque scheduleSync() retourne immédiatement, et à la fin pending = null sans qu'aucun drapeau dirty ne re-déclenche quoi que ce soit.

Combiné à 0.1, la boucle se referme : le serveur garde une version périmée, puis la réimpose au local au prochain démarrage.

Correctif (~10 lignes dans src/lib/sync.ts) : ajouter let dirty = false; scheduleSync() le pose ; déplacer la lecture du localStorage à l'intérieur de la boucle while ; dans le finally de pushNow, après pending = null, faire if (dirty) { dirty = false; return pushNow() }.

0.3 Un échec de synchro est 100 % silencieux (majeur)#

Pourquoi c’est ainsi

Une fonction asynchrone lancée depuis un setTimeout n'a plus d'appelant : personne n'attend son résultat, donc son échec ne remonte nulle part, sinon sous forme de rejet de promesse non capturé. Le canal prévu pour l'afficher existe déjà dans le module, mais aucun composant ne l'importe, si bien que trente secondes de tentatives infructueuses ressemblent, vues de l'utilisateur, à une sauvegarde réussie. Un échec silencieux est plus dangereux qu'un échec bruyant, parce qu'il empêche la seule réaction utile : refaire, ou copier ailleurs.

sync.ts:38 : window.setTimeout(pushNow, 800) — pushNow est async et jette new Error('Sync failed after retries') (sync.ts:58) sans aucun .catch() → unhandled promise rejection. La fonction syncStatus() (sync.ts:29-31) a été écrite exactement pour ça (le commentaire ligne 24 dit « lets the UI show a syncing… / sync failed indicator ») mais elle n'est importée nulle part.

Correctif : .catch() explicite au point de programmation ; transformer syncStatus en petit store observable (subscribe(cb) + 'idle'|'syncing'|'failed') affiché dans le header de src/App.tsx:180-201 à côté du bouton compte, avec un bouton « Réessayer » ; avertir sur beforeunload quand l'état est 'failed'.

0.4 localStorage.setItem sans try/catch : QuotaExceededError arrête tout, définitivement (majeur)#

Pourquoi c’est ainsi

Le stockage local d'un navigateur a une taille finie et refuse l'écriture au-delà, en levant une exception. Si personne ne l'attrape, rien de ce qui suit dans la fonction ne s'exécute : ni la remise à zéro de la file d'attente, ni le déclenchement de la synchro serveur — une seule écriture refusée coupe donc les deux chemins de sauvegarde d'un coup, pour toute la session. Le seuil arrive d'autant plus vite que chaque écran conserve deux versions complètes de son code source, ce qui double l'empreinte au profit d'une annulation à un seul niveau.

src/lib/project.ts:73 — flushProjectsNow() fait le setItem sans protection, appelé depuis un setTimeout (l.85) et depuis beforeunload (l.89) : personne ne capture. Si ça jette, pendingProjects = null (l.74) n'est jamais atteint et scheduleSync() (l.79) non plus. La sauvegarde locale ET la synchro serveur s'arrêtent, en silence. Ton blob réel pèse déjà 209 684 octets, et chaque écran stocke code ET previousCode (project.ts:26-28) — l'empreinte est quasi doublée pour rien.

Correctif : try/catch + remontée vers le canal syncStatus avec un message actionnable ; retirer previousCode du persisté (le garder dans un Map en mémoire dans ProjectView — il ne sert qu'à onRevertScreen, ProjectView.tsx:292-301) → empreinte divisée par ~2 pour zéro régression fonctionnelle.

0.5 Deux onglets Mocky s'écrasent mutuellement (majeur)#

Pourquoi c’est ainsi

Le stockage local est partagé entre tous les onglets d'un même site, mais il n'est pas surveillé : chaque onglet lit la liste des projets une fois à l'ouverture, la garde en mémoire, puis réécrit la liste entière à chaque sauvegarde. Un onglet qui n'a pas été prévenu d'un ajout fait ailleurs le supprime donc en écrivant sa propre version, puis pousse cette version tronquée au serveur. Le navigateur émet pourtant un événement fait pour cela, à destination des autres onglets uniquement ; il suffirait de l'écouter, et rien ne l'écoute.

Vérifié par grep sur tout src/ : aucune occurrence de addEventListener('storage', BroadcastChannel, visibilitychange ou pagehide. Chaque onglet charge les projets une fois au montage (project.ts:220) puis réécrit le tableau entier à chaque flush (l.73). Onglet A génère un écran ; onglet B déplace un cadre → son flush réécrit tout sans l'écran de A, puis pousse ce tableau tronqué au serveur. Même schéma pour DESIGN.md (src/lib/design.ts:28-29).

Correctif (~15 lignes) : useEffect dans useProjects (project.ts:219-224) écoutant window.addEventListener('storage', …) — l'événement n'est émis que vers les autres onglets, comportement exactement voulu. Idem dans design.ts.

0.6 reconcileOnLogin s'exécute deux fois par connexion (majeur)#

Pourquoi c’est ainsi

Une même responsabilité — réconcilier le local et le serveur après connexion — est écrite à deux endroits, dans la modale d'authentification et dans le composant racine. Deux exécutions concurrentes du même échange lisent et réécrivent la même donnée sans se coordonner : le résultat dépend alors de l'ordre d'arrivée des réponses réseau, et deux rechargements de page se disputent la main. Le problème n'est pas le coût des requêtes en double, c'est que la sauvegarde n'a plus de propriétaire unique.

Vérifié : src/components/AuthModal.tsx:47-51 fait enableSync(true) + await reconcileOnLogin() + reload conditionnel, et src/App.tsx:276-284 (onSignedIn) refait exactement la même chose. Deux GET /api/data concurrents, potentiellement deux PUT, deux chemins de reload en course.

Correctif : supprimer les lignes correspondantes de AuthModal.tsx — le modal authentifie et appelle onSignedIn, point. App.tsx reste seul propriétaire de la réconciliation.

0.7 Filet de sécurité#

Pourquoi c’est ainsi

Les correctifs précédents modifient le chemin qui écrit tes données, c'est-à-dire l'endroit du code où une régression ne se voit pas tout de suite et ne se rattrape pas. On écrit donc les tests avant, sur les scénarios exacts qui cassent, pour que le correctif soit vérifiable au lieu d'être seulement plausible. S'y ajoute une barrière d'erreur React, parce qu'une exception pendant le rendu démonte tout l'arbre : sans elle, une seule ligne fautive dans un gros composant donne une page blanche, sans message et sans retour possible.

  • src/lib/sync.test.ts (à créer) : faux api + faux localStorage, 4 cas — serveur plus récent, local plus récent, écritures pendant un push en vol, épuisement des retries.
  • src/lib/project.test.ts (à créer) : debounce 300 ms, flush sur beforeunload, setItem qui jette, événement storage.
  • src/components/ErrorBoundary.tsx (à créer, ~40 lignes) : src/main.tsx:10 monte sans aucun boundary (le seul du projet est injecté dans l'iframe, Preview.tsx:167). Une exception de rendu dans ProjectView.tsx (1 624 lignes, plein de screens.find(...) non gardés, ex. l.940 et l.410) = page blanche muette sans retour possible. Deux niveaux : autour de (bouton « Recharger ») et autour de dans App.tsx:220-231 (bouton « Retour aux projets » → goHome()), pour qu'un projet corrompu n'emporte pas les autres.

Effort lot 0 : 2-3 jours. Risque de régression : moyen (on touche le chemin de persistance — d'où les tests d'abord).


2. LOT 1 — Verrouiller le serveur (RISQUE)#

Pourquoi c’est ainsi

La gravité de tout ce lot dépend d'une variable qui n'est pas dans le code : l'instance est-elle joignable au-delà de la machine de son propriétaire. Le même défaut vaut « à corriger un jour » sur un poste isolé et « à corriger avant tout le reste » derrière un nom de domaine public, d'où une section qui commence par la décision plutôt que par les constats. Elle est placée en tête parce qu'une seule ligne de configuration, l'adresse sur laquelle le port est publié, fait basculer d'un cas à l'autre.

Décision préalable, à prendre avant d'ouvrir ce lot. Toute cette section suppose que l'instance est joignable au-delà de ta machine. docker-compose.yml:16-17 publie "8787:8787" sur toutes les interfaces, et server/index.js:642 fait app.listen(PORT) sans adresse de bind (donc 0.0.0.0).

  • Si Mocky reste sur ton PC / ton LAN de confiance → ce lot passe en priorité 3, après le design. Fais quand même 1.1 et 1.2 (30 minutes).
  • Si Mocky est derrière Caddy/Nginx et joignable depuis Internet → ce lot passe avant tout le reste, y compris le lot 0. Correctif de 2 minutes dans les deux cas : ports: - "127.0.0.1:8787:8787" dans docker-compose.yml:16-17.

1.1 Quatre routes coûteuses sans aucune authentification (critique)#

Pourquoi c’est ainsi

Dans Express, la protection d'une route est une question d'ordre : un contrôle d'identité ne s'applique qu'à ce qui est monté après lui. Ces quatre montages arrivent avant que la fonction d'authentification ne soit même définie : ce n'est donc pas une vérification mal écrite, c'est une absence de vérification. Ce qui change la nature du problème depuis que l'administrateur peut déposer une clé de modèle côté serveur, c'est que ces routes ne donnent plus seulement accès à des données : elles dépensent l'argent de l'hôte, écrivent des fichiers et en suppriment.

Chaîne de middlewares vérifiée dans server/index.js : app créé l.238, cookieParser l.239, en-têtes de sécurité l.242-248 (aucune auth), puis les montages. currentUser est défini l.286 et requireAdmin l.306 — donc après, et ils ne sont jamais appliqués aux routes suivantes :

RouteLigneCe qu'un anonyme peut faire
/__providerindex.js:279Inférence illimitée sur la clé LLM de l'admin. Pire pour kind: 'ollama' : dialect.js:127-137 construit url = base + subpath depuis req.originalUrl et provider-proxy.js:174 conserve la méthode → DELETE /__provider/api/delete avec {"name":"..."} (le champ name échappe à la réécriture de model, l.150) supprime un modèle sur l'Ollama de l'admin.
/api/text/visionindex.js:492SSRF en lecture. Vérifié ligne par ligne : quand aucun provider admin n'est configuré (état par défaut), baseUrl vient de l'en-tête x-provider-base (l.500) et assertSafeTarget n'est jamais appelé — contrairement à provider-proxy.js:129-138 et muse/llm.js:40. vision.js:104-113 renvoie jusqu'à 400 caractères du corps de la réponse interne. Balayage complet du LAN + 169.254.169.254 depuis Internet, sans compte.
/api/images/*index.js:625GET /library expose tous les prompts (= briefs clients) et les projects d'autrui ; library.zip exfiltre tout ; DELETE /:hash fait un fs.rmSync (library.js:219), sans corbeille. Et les hash ne sont pas un secret : GET /library les liste. Chaîne de destruction autonome.
/api/muse/dossierindex.js:613Dépense les tokens du provider admin (muse/routes.js:52-58, trusted: true). Avec useFetch:true, lance Chromium.

Correctif (~30 lignes, server/index.js) :

// avant la ligne 279function requireUser(req, res, next) { const u = currentUser(req); if (!u) return res.status(401).json({error:'auth'}); req.user = u; next() }
  • /__provider : refuser 401 si textConfig.target(profileFromRequest(req)) existe et currentUser(req) est null (le mode clé-navigateur peut rester anonyme : il ne coûte rien à l'hôte).
  • app.use('/api/images', requireUser, images.router) et app.use('/api', requireUser, createMuseRouter(...)).
  • DELETE /api/images/:hash → requireAdmin (ou propriétaire, cf. 1.4).
  • /api/text/vision → requireUser et assertSafeTarget(baseUrl + '/api/chat') dans un try/catch avant de construire target (index.js:500-505).
  • Restreindre le sous-chemin /__provider à une liste blanche (/api/chat, /api/tags) dans provider-proxy.js:121-124.

1.2 La garde SSRF est contournable (majeur)#

Pourquoi c’est ainsi

Empêcher un serveur d'aller chercher une adresse interne à la demande d'un tiers suppose de reconnaître toutes les manières d'écrire la même adresse — or elles sont nombreuses, et la garde compare ici des chaînes de caractères. Une adresse IPv4 déguisée en IPv6, ou 0.0.0.0, ne ressemble à aucun des motifs testés tout en atteignant réellement la machine locale. Surtout, la garde vérifie l'adresse demandée et non l'adresse finalement atteinte : les redirections étant suivies par défaut, il suffit qu'une cible autorisée réponde « va voir ailleurs » pour que le contrôle tombe en une étape.

server/provider-proxy.js:35-56. Vérifié par exécution de la fonction exportée :

  • http://[::ffff:127.0.0.1]/... passe — new URL(...).hostname vaut [::ffff:7f00:1], et la ligne 54 ne teste que ::1/[::1]/fe80/fc/fd ; une chaîne commençant par [ ne matche rien.
  • http://0.0.0.0/... passe — la regex IPv4 (l.41) matche mais a === 0 n'est dans aucune branche de isPrivate (l.44-49).
  • Les deux atteignent réellement la boucle locale (fetch testé).
  • Le fetch de provider-proxy.js:173-177 n'a pas d'option redirect → undici suit les 3xx par défaut. Un 302 vers http://169.254.169.254/latest/meta-data/… est suivi et le corps renvoyé (provider-proxy.js:246-249).

À l'inverse 127.1, 2130706433, 0177.0.0.1 sont correctement bloqués (normalisation par URL). Même garde partagée par server/muse/fetch/fetcher.js:79 ; et server/muse/fetch/robots.js:97 fait explicitement redirect: 'follow'.

Correctif (server/provider-proxy.js) : redirect: 'manual' + refus/re-validation de tout 3xx ; dépouiller les crochets IPv6 et rejeter les IPv4-mapped (::ffff:*), ::, 0.0.0.0/8, 100.64.0.0/10 ; résoudre via dns.lookup({all:true}) et re-vérifier chaque adresse avant l'appel. Répercuter sur robots.js:97.

1.3 /__provider bufferise un corps sans limite (majeur)#

Pourquoi c’est ainsi

Accumuler en mémoire le corps d'une requête sans borne revient à laisser le client décider de la mémoire consommée par le serveur. La limite de 25 Mo qui existe ailleurs ne s'applique pas ici : elle est montée plus loin dans la chaîne et sur un autre préfixe, et ce gestionnaire ne passe jamais la main à la suite. Le point qui transforme une lenteur en panne, c'est que l'assemblage final lève son exception à l'intérieur d'un écouteur d'événement, hors de toute chaîne de promesse : une exception que personne n'attrape arrête le processus Node entier.

provider-proxy.js:76-83 : readRawBody accumule tout puis Buffer.concat, aucune borne. Le express.json({limit:'25mb'}) de index.js:283 est monté après (l.279) et seulement sur /api ; en plus le handler /__provider est terminal (il n'appelle jamais next()). Détail aggravant non relevé initialement : au-delà de buffer.constants.MAX_LENGTH, Buffer.concat lève dans le listener req.on('end') (l.80), hors de toute chaîne de promesse → exception non capturée, crash du process (aucun uncaughtException dans tout server/).

Correctif : compteur d'octets + req.destroy() au-delà de 25 Mo → 413 ; AbortController avec deadline sur le fetch amont (l.173).

1.4 Bibliothèque d'images globale, sans propriétaire (majeur)#

Pourquoi c’est ainsi

Une application multi-comptes ne peut isoler que ce que son modèle de données sait attribuer. Ici la fiche d'une image enregistre le prompt, la graine, les dimensions et les projets, mais aucun identifiant d'utilisateur : il n'existe donc rien sur quoi filtrer, et aucun contrôle posé au niveau des routes ne rattrape cette absence sans changer d'abord la donnée écrite. Ce qui est exposé n'est pas anodin, puisque le prompt d'une image contient le brief réel, c'est-à-dire ce que le client a demandé.

server/images/library.js:151-163 : la métadonnée stockée est {hash, prompt, negative, provider, seed, width, height, createdAt, tags, projects, favorite} — aucun champ utilisateur. library.list() (l.195-207) ne filtre que query/project/favorites/slotType, et library.zip() (l.261-290) réinjecte prompt/negative/seed dans manifest.json. Les prompts contiennent le brief réel (src/lib/muse.ts:240-250 poste prompt: slot.prompt || slot.subject avec project: project.id).

Correctif : owner: userId dans la métadonnée, filtrage dans list() sauf admin, refus de remove/toggleFavorite si meta.owner !== req.user.id. Nécessite de passer l'utilisateur de routes.js jusqu'à library.js.

1.5 Rate-limit inopérant derrière un reverse proxy (moyen — mais 1 ligne)#

Pourquoi c’est ainsi

Un quota « par adresse IP » suppose de connaître l'adresse du visiteur ; derrière un reverse proxy, l'adresse vue par le serveur est celle du proxy, la même pour tout le monde. Express sait lire l'en-tête que le proxy ajoute pour rétablir l'adresse d'origine, mais seulement si on l'y autorise explicitement — refuser d'y croire par défaut est le bon choix, à condition de le configurer. Sans cela le compteur devient global à l'instance, et la protection se retourne : quelques tentatives suffisent pour empêcher tous les comptes légitimes de se connecter.

server/index.js:256 : req.ip || req.socket?.remoteAddress, et aucun app.set('trust proxy', …) (grep confirmé). Le README.md:187-199 documente pourtant Nginx avec proxy_pass. Résultat : req.ip = 127.0.0.1 pour tout le monde → le quota de 8 tentatives/minute est global à l'instance. 9 requêtes /api/login par minute et plus personne ne peut se connecter.

Correctif : app.set('trust proxy', process.env.TRUST_PROXY || false) avant cookieParser (l.238-239), documenter dans .env.example et README.md:108-115, déduire secure de req.secure plutôt que de NODE_ENV (l.301). Étendre authRateLimit à /api/images/generate, /api/muse/dossier, /__provider.

1.6 Sessions sans expiration serveur (moyen — sévérité corrigée à la baisse)#

Pourquoi c’est ainsi

La durée de vie inscrite dans un cookie est une consigne donnée au navigateur, pas une règle appliquée par le serveur : la seule expiration contraignante est celle qui est vérifiée au moment où le jeton est relu, et elle n'existe pas. La sévérité reste modérée parce que le cookie est marqué httpOnly, donc invisible au JavaScript de la page : le voler suppose déjà un accès aux fichiers ou au poste. C'est précisément pour cela que les droits des fichiers figurent dans le même constat — des jetons et des clés API lisibles par tout utilisateur local rendent cet accès trivial dès qu'il existe.

server/index.js:296 écrit sess.t, currentUser (l.286-292) ne le lit jamais ; le maxAge (l.302) est une simple instruction au navigateur ; aucun élagage nulle part (alors que les JTI SSO sont bien élagués l.155-156).

À corriger, mais ce n'est pas une porte d'entrée : le cookie est httpOnly (l.299), donc une XSS ne peut pas l'exfiltrer — il faut un accès fichier/sauvegarde/poste. Et deux affirmations initiales sont fausses : la rétrogradation admin→user prend effet immédiatement (currentUser relit users.json à chaque requête, l.291), et il n'existe aucune route de changement de mot de passe.

Correctif : dans currentUser, rejeter et supprimer une session dont Date.now() - sess.t > MAX_AGE ; TTL glissant ; purge au démarrage sur le modèle de consumeJti (l.151-163) ; POST /api/logout-all (la boucle existe déjà l.592). Et passer { mode: 0o600 } aux writeFileSync de server/index.js:76, text/config.js:212, images/config.js:194 (aujourd'hui 0644 : clés API et jetons de session en clair lisibles par tout utilisateur local).

1.7 CSRF : uniquement le login-CSRF SSO (moyen — sévérité corrigée)#

Pourquoi c’est ainsi

La plupart des requêtes forgées depuis un autre site sont déjà bloquées ici, mais par accident : le serveur n'accepte que du JSON, qu'un formulaire distant ne peut pas envoyer sans déclencher un contrôle préalable du navigateur que rien ne satisfait. Reste le seul cas qui échappe à ces deux barrières, la navigation directe en GET, c'est-à-dire exactement ce que SameSite=Lax laisse passer, et c'est par là que le retour d'authentification externe pose une session. Une session posée par un tiers n'est pas une simple usurpation d'accès : la réconciliation démarre aussitôt et pousse le contenu local vers le compte choisi par l'attaquant.

Ce qui est réfuté : « une app compromise sur *.example.com peut forger POST /api/admin/users » — faux. express.json n'accepte que application/json (body-parser met req.body = {} sinon) ; un form cross-site ne peut envoyer que urlencoded/text-plain/multipart → 400 ; et un vrai JSON déclenche un preflight CORS que le serveur ne satisfait jamais (aucun middleware cors dans tout le dépôt).

Ce qui reste réel, et sérieux : app.get('/sso/dashy/callback') (index.js:393) pose la session via setSession (l.423) sur une navigation GET de premier niveau — exactement le cas où SameSite=Lax laisse passer. La vérification du state est purement cliente (src/lib/sso.ts:81) et jamais montrée : App.tsx:34-39 fait setSsoError(), mais ssoError ne s'affiche que dans , qui ne s'ouvre que si api.me() renvoie null (App.tsx:45-46) — or le cookie vient d'être posé. Aggravant : App.tsx:49 lance immédiatement reconcileOnLogin(), donc sync.ts:84 pousse tout ton localStorage vers le compte de l'attaquant (ou l.80 écrase tes projets par les siens).

Correctif : cookie de state posé côté serveur avant la redirection vers Dashy et vérifié avant setSession (l.423) ; sameSite: 'strict' ; vérification Origin/Sec-Fetch-Site contre MOCKY_ORIGIN ; rendre MOCKY_ORIGIN obligatoire quand ssoEnabled — sinon expectedAudience (l.405-406) retombe sur req.get('host'), ce qui neutralise le contrôle aud et ouvre une redirection ouverte (curl -H 'Host: evil.tld' … → 302 vers http://evil.tld/).

Effort lot 1 : 2-3 jours (1.1 + 1.2 + 1.3 = 1 jour et couvrent 80 % du risque). Risque de régression : faible à moyen — 1.1 casse le mode « frontend seul sans compte » s'il devait revenir (voir lot 2).


3. LOT 2 — Refermer l'évasion du sandbox navigateur (RISQUE)#

Pourquoi c’est ainsi

Mocky exécute dans ton navigateur du code écrit par un modèle : la seule chose qui sépare ce code de tes données est l'isolation du navigateur, et cette isolation tient entièrement à la valeur exacte d'un attribut. La section commence par constater que l'iframe d'aperçu est correctement configurée, précisément pour qu'on ne « l'améliore » pas plus tard en ajoutant un drapeau : chaque drapeau absent est une décision, pas un oubli. Ce qui suit se range en deux familles : une seconde frame, privilégiée, qui contourne entièrement cette isolation, et les deux choses que l'attribut de bac à sable ne règle pas — ce que le document a le droit d'envoyer sur le réseau, et qui a le droit de lui parler.

Le sandbox principal est correct : src/components/Preview.tsx:425 → sandbox="allow-scripts" sans allow-same-origin (vérifié). Origine opaque : pas de localStorage, pas de cookie, pas de DOM parent, pas de popup, pas de navigation top-level, pas de download, pas de form. Les flags absents sont exactement les bons — ne surtout pas ajouter allow-popups ou allow-top-navigation.

Trois trous la contournent.

2.1 capture.ts remonte une iframe same-origin (critique)#

Pourquoi c’est ainsi

Une iframe remplie par srcdoc et dotée de allow-same-origin n'est plus isolée : elle hérite de l'origine de l'application, donc le code du modèle y accède au stockage local, aux cookies de session et au document parent exactement comme le ferait Mocky lui-même. Tout ce que l'aperçu refuse soigneusement est ici accordé pour la durée d'une capture. Ce qui rend le constat critique n'est pas la durée mais le déclencheur : la capture est lancée par une action produit ordinaire, un glissement en mode annotation, et non par une manipulation exotique.

src/lib/capture.ts:152 — vérifié : iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin') avec srcdoc (l.154), qui exécute le code du modèle (l.109/114). srcdoc + allow-same-origin ⇒ l'iframe hérite de l'origine de Mocky. Le commentaire l.18-19 l'assume (« Fine for a self-hosted tool »). Déclencheur : ProjectView.tsx:247-268 → mode annotation (l.894-897) — une action produit normale, un clic.

Pendant la capture, le code généré par le LLM peut lire localStorage['mocky.settings.v1'] (ta clé fournisseur en clair, settings.ts:28,53), appeler fetch('/api/admin/users', {credentials:'same-origin'}) — le cookie mocky_sess est httpOnly mais envoyé automatiquement — donc créer un compte admin, écraser tous tes projets via PUT /api/data, et réécrire parent.document. La seule barrière est la bienveillance du modèle.

Correctif : retirer allow-same-origin et laisser html2canvas tourner dans l'iframe null-origin — il y est déjà chargé (capture.ts:122, /vendor/html2canvas.min.js), et toDataURL() fonctionne en origine opaque tant que le canvas n'est pas teinté. Le seul teinteur réel est : ajouter Access-Control-Allow-Origin: * sur server/images/routes.js:91-102 et useCORS: true dans les options html2canvas (capture.ts:135). Repli si le canvas reste teinté : servir une page /capture.html depuis un second port (origine distincte, même machine) et remonter le dataURL par postMessage.

2.2 Cette même iframe same-origin charge du JS depuis un CDN non épinglé (critique)#

Pourquoi c’est ainsi

Une balise de script qui pointe vers une URL sans version ni empreinte délègue à un tiers — et à quiconque contrôle le DNS entre lui et toi — le droit d'exécuter le code de son choix, à ton origine, indéfiniment. C'est déjà gênant dans un aperçu isolé ; dans la frame du constat précédent, qui partage l'origine de l'application, c'est un accès direct à tes clés et à tes projets. L'invariant qui interdit les CDN existait pourtant : son test ne regardait que le registre déclaratif des capacités, jamais les balises écrites en dur dans la construction du document, donc il passait au vert sans rien vérifier.

src/lib/capture.ts:101-102 : https://unpkg.com/@babel/standalone/babel.min.js — aucune version, aucun integrity. capture.ts:120 et Preview.tsx:107 : https://cdn.tailwindcss.com — idem. Compromission CDN (ou simple DNS/proxy sur ton LAN) = exécution JS à l'origine de Mocky.

Contradiction documentée : Preview.tsx:22-27 affirme « never a CDN », et docs/adr/001-muse.md:69 pose l'invariant I3 (« No CDN