Lab · Autopsie · 18 avril 2026 · 14 min de lecture
Quand les produits vibe-codés s’effondrent — cinq incidents que j’ai eu à nettoyer
Le vibe-coding a démocratisé la création logicielle d'une manière qu'on n'avait jamais vue. C'est fantastique— vraiment, sans ironie. Un fondateur seul peut aujourd'hui prototyper en 48 h ce qui demandait une équipe de 4 il y a cinq ans.
Et puis ce produit arrive en prod. Avec de vrais utilisateurs. Et de vrais attaquants. Et là, les ennuis commencent.
Préambule : l’IA ne ment pas, elle livre ce qu’on lui demande
Avant de passer aux cinq cas, mettons une chose au clair. Le problème n'est pas Claude, GPT-5, Cursor ou Lovable. Ces outils livrent du code qui fonctionne— et c'est précisément le piège. Fonctionnern'est pas être sûr.
Quand vous demandez « une page de contact qui envoie un email », l'IA vous livre exactement ça. Elle ne vous demande pas si vous avez pensé au rate-limit, à la validation d'entrée, au cap sur la taille du body, au honeypot, au SPF/DKIM/DMARC du domaine d'envoi. Ce n'est pas son job — c'est le vôtre, ou celui de quelqu'un que vous embauchez.
Voici cinq cas où ce quelqu'un, c'était moi, appelé après l'incident. Anonymisés : noms, secteurs, chiffres modifiés à la marge. Les leçons, elles, sont exactes.
Cas 1 — Le SaaS B2B qui a exposé 47 000 emails clients
Contexte
Plateforme de gestion de leads pour agents immobiliers, fondateur solo, 200 clients payants. MVP construit avec Cursor en 6 semaines. Stack Next.js + Supabase. Tout tournait nickel.
L’incident
Un utilisateur curieux remarque que les URLs de l'espace client suivent le schéma /clients/[uuid]. Il essaie de modifier l'UUID. Bingo: il accède à l'espace d'un autre client. Il poste sur LinkedIn. Le post fait 3 000 likes en 48 h.
Cause technique
Le code généré faisait des requêtes Supabase comme ça :
// Code généré — vulnérable
const { data } = await supabase
.from('clients')
.select('*')
.eq('id', params.uuid);
return Response.json(data);Aucune vérification que params.uuidappartenait à l'utilisateur authentifié. C'est ce qu'on appelle un IDOR(Insecure Direct Object Reference), classé OWASP A01:2021. La RLS (Row Level Security) de Supabase n'était pas activée — alors qu'elle estdésactivée par défaut, ce que Cursor n'a évidemment pas signalé.
Coût de la remédiation
- 14 j-h de mission urgente : 14 000 € HT
- Notification CNIL obligatoire (4 200 personnes concernées)
- 3 désabonnements client sur les 200, soit ~7 200 € de MRR perdu/an
- Article LinkedIn qui pop sur Google pendant 6 mois
Ce qu’il fallait faire dès le jour 1
-- Activer RLS sur chaque table ALTER TABLE clients ENABLE ROW LEVEL SECURITY; -- Policy : un user voit ses propres lignes CREATE POLICY "self_only" ON clients FOR SELECT USING (auth.uid() = owner_id);
Deux lignes de SQL. Cinq minutes. Ça n'a pas été fait.
Cas 2 — Le formulaire de contact qui a fait crasher le serveur
Contexte
Site vitrine d'une PME de service, 8 personnes. Vibe-codé avec v0 puis hébergé sur un petit VPS Hetzner à 6 €/mois. Trafic faible — 200 visites/jour.
L’incident
Un dimanche soir, le site devient inaccessible. Lundi matin, le fondateur découvre que son serveur a reçu 240 000 POST sur le formulaire de contacten 12 heures. Le mailbox de réception a explosé (1,4 Go d'emails spam), le disque du VPS aussi.
Cause technique
Le formulaire envoyait vers une API route Next.js qui :
- Acceptait n'importe quel Content-Type
- Lisait le body sans cap de taille
- Faisait un appel à
sendmailsans queue - N'avait aucun rate-limit, ni IP-based ni global
- Pas de honeypot, pas de CAPTCHA, pas de signature de soumission
Un bot scanner avait trouvé le endpoint. Coût pour l'attaquant : ~0,12 $ d'infra. Coût pour la victime : site HS 36 h, perte estimée d'un prospect chaud à 28 000 €.
Ce qu’il fallait faire
Exactement ce que fait le formulaire de cette démo :
- Content-Type strict
application/json— refus 415 sinon - Cap body à 4 KiB (déclaré ET vérifié après lecture) — refus 413
- Rate-limit IP-based : 5 envois/h max
- Honeypot CSS-hidden, réponse 204 silencieuse si rempli
- Validation Zod stricte côté serveur
- Mail via service tiers (Resend, Postmark) avec SPF/DKIM/DMARC, pas via sendmail local
Temps d'implémentation correcte : 3 h. Temps de réparation après incident : 4 jours et 4 500 €.
Cas 3 — La clé Stripe en clair sur GitHub depuis 14 mois
Contexte
E-commerce indépendant, 4 personnes. MVP Next.js + Stripe, vibe-codé. Repo GitHub public car « ça aide quelqu'un qui voudrait voir comment c'est fait ».
L’incident
Un soir, 14 000 € de paiements suspects depuis le compte Stripe vers des comptes en Lettonie. Stripe gèle le compte, envoie une alerte de compromission de clé secrète.
Cause technique
Le commit initial de l'app contenait :
// app/lib/stripe.ts
import Stripe from 'stripe';
const stripe = new Stripe(
'sk_live_51N...REDACTED...XYZ', // ⚠️ clé live en clair
{ apiVersion: '2024-06-20' }
);L'IA avait écrit le code, le fondateur avait copié sa clé Stripe en dur « pour tester rapidement », puis avait oublié de la déplacer dans .env.local. Le commit a été poussé public. Pendant 14 mois.
Les bots qui scrapent GitHub à la recherche de patterns sk_live_ et sk_test_ sont très efficaces. Délai médian entre push et exploitation d'une clé Stripe publique : moins de 3 minutes(étude GitGuardian 2024). Le fondateur a eu de la chance d'avoir 14 mois.
Ce qu’il fallait faire
.env.localdans.gitignoredès le premier commit (Next.js le fait par défaut, mais il faut le respecter)gitleaksen pre-commit hook : refuse de commit si un pattern de secret est détectégitleaksen CI sur chaque PR : même filet, côté serveur- Vérification mensuelle GitGuardian / TruffleHog sur l'historique Git complet (les secrets passés ne disparaissent jamais d'un rebase, il faut les rotater)
Le starter Cyberviseur sur lequel cette démo est construite inclut gitleaksen CI par défaut. Ce n'est pas pour faire joli — c'est pour empêcher exactement ce cas-là.
Cas 4 — Le « contact admin » qui acceptait n’importe qui
Contexte
Tableau de bord interne d'une scale-up de 80 personnes. Construit en 3 semaines avec Claude Code par le CTO entre deux réunions. Authentification via email magic link maison.
L’incident
Un employé licencié garde l'accès au tableau de bord pendant 5 mois. Il télécharge la base clients complète (3 200 contacts, données de signature de contrat, marges) et la revend à un concurrent pour 18 000 €.
Cause technique
Le code généré contenait :
// app/api/admin/route.ts — code généré
export async function GET(request: Request) {
const session = await getSession(request);
if (!session) return Response.json(
{ error: 'unauthorized' }, { status: 401 }
);
// ⚠️ Aucune vérification que session.user
// appartient au groupe "admin"
const clients = await db.client.findMany();
return Response.json(clients);
}Toute personne authentifiée (donc tout employé, actuel ou passé) pouvait appeler /api/admin/*et obtenir l'intégralité de la base. La désactivation des comptes employés à la sortie était... manuelle et oubliée.
Ce qu’il fallait faire
- Vérification de rôle systématique sur chaque endpoint sensible. Idéalement via middleware Next.js (
src/proxy.tsdans le starter Cyberviseur) - Logs d'accès aux endpoints admin, avec alerte sur volume anormal
- Processus de off-boarding écrit + checklist (révocation tous les SSO, GitHub, Notion, Linear, Stripe, etc.)
- Refresh des sessions sur révocation : si vous coupez l'accès dans votre IdP, l'ancienne session doit expirer en quelques minutes max, pas en jours
Cas 5 — L’upload d’avatar qui pouvait écrire dans /etc/passwd
Contexte
App de réseau social de niche, 600 utilisateurs. Vibe-codée avec Lovable. Upload d'avatar utilisateur côté serveur.
L’incident
Heureusement, pas d'incident exploité — j'ai trouvé ça en audit avant que ça arrive. Mais le potentiel était catastrophique.
Cause technique
L'upload côté serveur sauvait le fichier comme ça :
// app/api/avatar/route.ts
const file = await request.formData();
const avatar = file.get('avatar') as File;
const filename = avatar.name; // ⚠️ contrôlé par client
await fs.writeFile(
`./public/avatars/${filename}`,
Buffer.from(await avatar.arrayBuffer())
);Le filename venait du client. Sans normalisation. Un attaquant pouvait envoyer un fichier nommé ../../../etc/cron.d/payloadet l'app l'écrivait littéralement à cet endroit (selon les permissions du process Node, bien sûr). C'est ce qu'on appelle un path traversal.
En plus de ça : pas de cap sur la taille du fichier, pas de validation du type MIME, pas de vérification du contenu réel (un .png peut contenir du JavaScript ou un binaire ELF).
Ce qu’il fallait faire
- Ne JAMAIS utiliser un nom de fichier contrôlé par le client. Générer un UUID côté serveur, point.
- Cap taille (5 Mo pour un avatar, c'est déjà énorme)
- Validation du type MIME par sniffing(lire les magic bytes), pas par l'extension ni par le Content-Type client
- Stocker hors du
./public/servi par Next : sur S3, R2, ou un volume séparé. Ne pas mélanger uploads utilisateur et assets statiques - Servir via un endpoint qui valide le droit d'accès, pas en exposant le fichier brut
La leçon générale
Cinq cas, cinq familles d'erreurs. Aucun n'était subtil.Tous étaient documentés dans l'OWASP Top 10 depuis au moins 2017. Tous auraient été détectés par une revue de 4 h faite par quelqu'un de compétent.
Le vibe-coding accélère la création. Il n'accélère pasl'acquisition d'une culture de la sécurité. Ces deux courbes divergent — et quand l'écart devient trop grand, ça casse.
Le bon réflexe n'est pas d'arrêter de vibe-coder. C'est de mettre quelqu'un qui sait sur la trajectoire de vos commits avant qu'ils touchent la prod. Ça peut être un dev senior recruté, un consultant externe, ou un audit ponctuel. Mais quelqu'un.
Pour aller plus loin
- OWASP Top 10:2021 — la lecture de base de tout dev qui livre en prod
- OWASP Cheat Sheet Series — checklists pratiques par sujet (auth, session, upload, CSP…)
- cybermalveillance.gouv.fr — référent étatique français en cas d'incident
Vous reconnaissez votre situation ?
Si l'un de ces cas vous a fait sentir un petit pincement au moment de lire — c'est probablement un signal. Mieux vaut un appel diagnostic gratuit de 45 min maintenant qu'une cellule de crise dans 6 mois.