15 août 2026

On a trouvé 2 failles RLS critiques — sur notre propre app

Vetora existe pour trouver les failles que le "vibe coding" laisse traîner dans les apps construites avec Bolt, Lovable, v0 ou Cursor — clés exposées, RLS mal configuré, secrets committés. Ironiquement, en construisant Vetora lui-même sur Supabase, on a laissé passer exactement ce genre de faille dans notre propre schéma de base de données. Voici ce qui s'est passé, pourquoi, et comment on l'a corrigé.

Le contexte

Comme beaucoup d'apps générées par des outils IA, Vetora n'a pas de compte utilisateur : les scans sont créés et consultés via l'URL, sans connexion. La migration initiale (générée par l'assistant IA du scaffold) a créé deux tables, scans et findings, avec Row Level Security activé — jusque-là, rien d'anormal. Le problème était dans le détail des policies.

Faille 1 : débloquer un paiement sans payer

La table scans a une colonne paid qui détermine si le rapport complet (49€) est débloqué. La policy UPDATE générée ressemblait à ça :

CREATE POLICY "anon_update_scans" ON scans FOR UPDATE
  TO anon, authenticated USING (true) WITH CHECK (true);

La clé anon de Supabase est publique par design — elle est envoyée à chaque visiteur dans le bundle JavaScript du site (NEXT_PUBLIC_SUPABASE_ANON_KEY). Avec une policy UPDATE en USING (true), n'importe qui pouvait ouvrir la console du navigateur et taper :

supabase.from('scans').update({ paid: true }).eq('id', scanId)

Résultat : le rapport complet débloqué, sans passer par Stripe, sans payer un centime.

Faille 2 : forger ou effacer des résultats de scan

Même problème sur la table findings, qui stocke les vulnérabilités détectées : des policies INSERT et UPDATE ouvertes au rôle anon. N'importe qui pouvait insérer de fausses failles sur un scan existant, ou réécrire les descriptions et instructions de correction d'un vrai résultat — de quoi saboter la crédibilité d'un rapport, dans les deux sens.

La cause racine : des policies inutiles

En creusant, on a réalisé que ces policies d'écriture n'avaient jamais de raison d'exister. Toutes les écritures réelles de l'app — création d'un scan, insertion des findings, mise à jour de paid après un vrai paiement Stripe — passent côté serveur par un client service_role, qui contourne RLS entièrement. Les policies ouvertes à anon ne servaient donc à rien côté légitime : elles n'étaient qu'une surface d'attaque, jamais utilisée par le code, jamais testée, jamais remarquée.

C'est un piège classique quand une IA génère un schéma Supabase complet d'un coup : elle donne par défaut des accès larges "au cas où" le frontend en aurait besoin, sans vérifier si le code appelant les utilise vraiment.

Le correctif

On a supprimé toutes les policies INSERT/UPDATE pour anon et authenticated sur les deux tables, en ne gardant que le SELECT public (volontaire : les rapports sont partagés par URL, sans compte). Aucune fonctionnalité n'a été cassée, puisque aucune n'en dépendait :

DROP POLICY IF EXISTS "anon_insert_scans" ON scans;
DROP POLICY IF EXISTS "anon_update_scans" ON scans;
DROP POLICY IF EXISTS "anon_insert_findings" ON findings;
DROP POLICY IF EXISTS "anon_update_findings" ON findings;

Trois questions à te poser sur tes propres policies RLS

  • Est-ce que mes écritures passent déjà par un client service_role côté serveur ? Si oui, mes policies INSERT/UPDATE ouvertes à anon servent-elles vraiment à quelque chose ?
  • Est-ce qu'une colonne comme paid, role ou is_admin est modifiable directement depuis le client, sans passer par une vérification serveur ?
  • Est-ce que USING (true) apparaît sur une policy d'écriture, pas seulement de lecture ?

Si tu réponds "oui" à l'une de ces questions, tu as probablement le même problème qu'on avait. Scanne ton app pour vérifier.