Contourner la validation SQL en lecture seule dans DBHub

Read in English

DBHub est le serveur MCP de Bytebase. Il se place entre un agent LLM et une instance Postgres, MySQL ou SQL Server, et c’est lui qui décide quelles requêtes atteignent la base. Le mode lecture seule est l’option qu’on active quand l’agent doit regarder sans toucher.

Sur les connecteurs MySQL et MariaDB, l’avis GHSA-j656-3hf2-fvjc indique que cette option peut être contournée, jusqu’à DROP TABLE inclus. Il concerne @bytebase/dbhub en dessous de 0.22.6 et porte une sévérité moyenne. Aucune CVE n’a été attribuée, ce qui mérite d’être relevé : beaucoup de travail se fait à ce niveau de sévérité et n’arrive que rarement dans un flux que quelqu’un surveille.

Le bug est un désaccord de deux caractères

src/utils/sql-parser.ts cherche -- et traite ce qui suit comme un commentaire, quel que soit ce caractère. MySQL et MariaDB sont plus stricts : -- n’ouvre un commentaire que si le caractère suivant est un espace, un caractère de contrôle, ou la fin de ligne. Sinon, les deux tirets sont lus comme deux signes moins et le reste de la ligne reste du SQL ordinaire.

Les deux systèmes ne sont donc pas d’accord sur l’endroit où se termine la requête. Le validateur, croyant avoir trouvé un commentaire, inspecte une chaîne tronquée. Le connecteur exécute ensuite l’originale.

Cette seconde moitié est la partie qu’on oublie. src/tools/execute-sql.ts découpe l’entrée en requêtes et valide chacune avec isReadOnlySQL. Mais les connecteurs MySQL et MariaDB ouvrent leur pool avec multipleStatements: true et passent la chaîne non modifiée au driver en un seul appel. La validation a eu lieu sur une chaîne dérivée. L’exécution a eu lieu sur la vraie. Tout ce qui survit au découpage, ou s’y cache, n’est jamais vérifié.

Ce que cela signifie en pratique

Le validateur inspecte une chose, la base en exécute une autre, et le mode lecture seule devient indicatif plutôt que contraignant. L’exemple de l’avis est une écriture ou une instruction DDL atteinte via le connecteur MySQL ; la forme du bug, c’est que l’avis de la passerelle n’est pas celui qui s’exécute.

Je ne l’ai pas reproduit sur une version précise, donc considérez le mécanisme comme l’affirmation de l’avis plutôt que comme mon résultat de test. La plage de versions fait foi.

Le reproduire sans risque

Il vous faut une base jetable et une version de DBHub épinglée. Ne faites pas cela contre quelque chose qui compte pour vous, et ne pointez pas vers une instance partagée même en lecture seule, parce que toute la prémisse est que le mode lecture seule peut ne pas tenir.

docker run -d --rm -e MARIADB_ROOT_PASSWORD=dev -p 3306:3306 mariadb:11

Créez une table avec une ligne que vous pouvez perdre, installez @bytebase/dbhub@0.22.5, pointez-le vers cette base avec le mode lecture seule activé, et envoyez une chaîne à deux requêtes dont la première se termine par -- immédiatement suivi d’un caractère non blanc. Les octets précis sont dans l’avis ; l’intérêt de l’exercice est de voir si votre déploiement se comporte comme l’avis le décrit.

Si l’écriture passe, vous avez votre réponse. Si elle ne passe pas, vous êtes sur une version corrigée ou un driver qui traite l’entrée différemment.

Ne corrigez pas cela en parsant plus fort

Le réflexe est d’ajouter une regex de plus, une vérification de dialecte de plus, un normaliseur de plus. C’est une liste de refus, et les listes de refus perdent : il y a toujours une autre forme de commentaire, une autre règle de citation, une autre façon d’écrire une requête que le parseur n’a pas vue. Cet avis est un cas de l’espèce.

Faites respecter la règle par le moteur. Donnez à l’agent un rôle de base de données qui ne détient que SELECT sur les schémas dont il a besoin, et connectez-vous avec ce rôle. MySQL et MariaDB refuseront l’écriture quel que soit ce que le parseur de DBHub a conclu, parce que le droit est vérifié par le serveur. Ajoutez un statement_timeout tant que vous y êtes, et un hint MAX_EXECUTION_TIME si vous tournez sur MySQL.

Deux choses de plus avant de livrer. Utilisez une connexion séparée et un rôle séparé par mode plutôt qu’un flag sur un pool partagé, pour qu’une erreur de configuration ne puisse pas élever silencieusement les privilèges d’un agent. Et pointez le trafic en lecture seule vers un réplica quand vous en avez un, parce que la protection la moins chère contre une mauvaise requête, c’est de ne pas être sur le primaire.

Quand ne pas le faire

Un rôle en lecture seule ne vous sauve pas des requêtes coûteuses. Un SELECT avec une jointure négligée peut encore saturer un primaire, et aucun droit ne l’empêche. Des timeouts et un réplica sont la réponse là, pas les permissions.

Et si votre agent a réellement besoin d’écrire, rien de tout cela ne s’applique. Il vous faut une autre conception : une table de staging, une étape d’approbation, une piste d’audit, et un humain capable de dire non. Un parseur plus strict ne remplace aucun de ces éléments, et cet avis en est une démonstration assez propre.

← Tous les articles