Retour au blog
Sécurité IA Native · 9 min de lecture

Sécuriser le NL2SQL : empêcher l’injection SQL générée par un LLM

Affane Daylami · Fondateur· 24 août 2026

Un endpoint NL2SQL transforme une question en langage naturel en requête SQL, puis cette requête s’exécute contre votre base. Le risque n’est donc pas l’injection SQL classique, une chaîne mal échappée dans un formulaire : c’est un modèle de langage qui décide seul du SQL à écrire. Demander poliment au modèle de ne générer que des SELECT dans le prompt système n’empêche rien structurellement, c’est une instruction, pas un contrôle d’accès. La seule méthode qui tient consiste à valider le SQL généré après coup, avec un parseur qui construit son arbre syntaxique.

Ce guide détaille la méthode qui fonctionne réellement : restriction structurelle à SELECT, liste blanche fermée de fonctions, plafond de lignes obligatoire, et verrouillage du schéma interrogé. Chaque étape s’appuie sur le validateur réellement implémenté dans le moteur NL2SQL d’Aurabase, une capacité de son IA native intégrée au backend, pas un service tiers assemblé après coup. Si le sujet est nouveau pour vous, notre présentation du NL2SQL pose les bases, et le tutoriel pas-à-pas montre comment construire l’endpoint complet.

L’essentiel
  • Le prompt engineering (« ne génère que du SELECT ») n’est pas un contrôle de sécurité : un modèle peut halluciner, se laisser orienter par une question ambiguë, ou tout simplement ignorer la consigne.
  • La validation qui tient est structurelle : un parseur construit l’arbre syntaxique (AST) de la requête et rejette par défaut tout ce qui n’est pas explicitement autorisé.
  • Quatre couches concrètes bornent le risque : SELECT strict (ni sous-requête, ni CTE, ni UNION), liste blanche fermée de dix fonctions, LIMIT obligatoire et plafonné, accès bloqué au catalogue système et aux schémas hors tenant.
  • Le schéma interrogé doit venir du serveur, jamais d’un champ de la requête client : sinon rien n’empêche un appelant de fournir son propre schéma pour contourner la validation.
  • Chez Aurabase, ce validateur (crate Rust sqlparser) est testé avec des cas adversariaux documentés dans le code : fonctions interdites cachées dans FILTER, dans un ORDER BY intra-agrégat, ou dans OFFSET.
#
Le vrai problème

Pourquoi une instruction dans le prompt système ne bloque rien

Un prompt système qui dit « génère uniquement des requêtes SELECT » est une préférence, pas une barrière. Le modèle la respecte la plupart du temps parce qu’il a été entraîné à suivre les instructions, pas parce qu’une contrainte technique l’empêche physiquement d’écrire autre chose. Deux classes de défaillance rendent cette confiance insuffisante en production.

La première vient de la question elle-même. Un utilisateur, mal intentionné ou simplement créatif dans sa formulation, peut orienter la question de façon à pousser le modèle vers un SQL qu’il n’aurait pas dû écrire : une jointure vers une table sensible, un filtre qui contourne la logique attendue, un appel de fonction système. Le modèle ne distingue pas une question légitime d’une question conçue pour le manipuler.

La seconde ne nécessite aucune malveillance. Un modèle peut halluciner un nom de table, oublier le LIMIT que le prompt demandait, ou générer un SELECT * sans aucune restriction sur une table volumineuse. Le résultat est le même dans les deux cas : un SQL potentiellement coûteux ou intrusif, qui a passé le filtre du prompt et s’apprête à s’exécuter contre une vraie base.

L’image qui aide
Le prompt système reste utile, il oriente le modèle vers le bon résultat la majorité du temps. Mais un panneau « accès interdit » n’arrête personne qui ne sait pas lire, ou qui décide de l’ignorer. Il faut une porte fermée derrière, pas seulement un panneau devant.
#
Étape 1

Parser le SQL généré en arbre syntaxique, jamais en chaîne brute

La première ligne de défense consiste à parser le SQL produit par le modèle avec un vrai analyseur syntaxique pour le dialecte cible, puis à valider la structure obtenue, pas le texte brut. Une recherche de mots interdits dans la chaîne de caractères (« DROP », « DELETE », « ; ») se contourne trivialement : casse différente, commentaire inséré au milieu d’un mot-clé, guillemets typés. Un arbre syntaxique décrit sans ambiguïté ce que la requête fait réellement.

Aurabase implémente cette étape avec la crate Rust sqlparser et son dialecte PostgreSqlDialect, dans services/aura-ai/src/nl2sql/mod.rs. Avant même le parsing, un premier filtre lexical rejette deux constructions difficiles à raisonner correctement une fois dans l’arbre : le dollar-quoting ($$...$$), qui peut cacher un contenu arbitraire dans une chaîne, et les commentaires multi-lignes (/* */), qui peuvent masquer la fin réelle d’une instruction.

services/aura-ai/src/nl2sql/mod.rs
RUST
let dialect = PostgreSqlDialect {};
let mut statements = Parser::parse_sql(&dialect, sql)
.map_err(|e| AuraError::BadRequest(format!("Erreur de parsing SQL: {}", e)))?;
if statements.len() > 1 {
return Err(AuraError::BadRequest("Multi-statements non autorisés".to_string()));
}

Ce rejet du multi-statement bloque à lui seul la forme la plus connue d’injection SQL par empilement : SELECT * FROM users; DROP TABLE users;--. Le parseur ne renvoie qu’une seule instruction exploitable, la seconde n’est simplement jamais atteinte, quelle que soit la façon dont elle est formulée dans la question d’origine.

#
Étape 2

Restreindre structurellement à un SELECT simple

Une fois l’arbre obtenu, la validation la plus large consiste à n’accepter qu’un seul type de nœud racine, une requête (Statement::Query), et à rejeter tout le reste : INSERT, UPDATE, DELETE, DROP, CREATE, ALTER. Ce n’est plus une consigne de prompt, c’est une condition sur le type de l’objet parsé, qu’aucune formulation habile de la question ne peut contourner.

À l’intérieur même d’un SELECT, plusieurs constructions restent dangereuses et méritent leur propre rejet explicite :

Construction rejetéePourquoi elle est dangereuse
CTE / WITHPeut enchaîner une logique additionnelle non prévue avant le SELECT final.
Sous-requêtes, UNION / INTERSECT / EXCEPTÉlargit la surface de ce qu’une seule question peut faire en une seule requête.
SELECT ... INTOCrée une table : une écriture déguisée en lecture.
FOR UPDATE / FOR SHAREPose des verrous, risque de contention avec le trafic de production.
Fonctions-table (generate_series, pg_read_file...)Accès système ou déni de service via des lignes générées à la demande.

Un cas de test tiré du dépôt illustre concrètement le dernier point : SELECT * INTO backup FROM users est rejeté, alors qu’il ne contient ni mot-clé d’écriture visible ni fonction suspecte. La forme de la requête suffit à la disqualifier.

#
Étape 3

Liste blanche de fonctions, pas liste noire

Une liste noire de fonctions interdites (pg_sleep, pg_read_file, dblink...) oblige à anticiper chaque fonction dangereuse une par une, alors que Postgres en expose plusieurs centaines. Une liste blanche inverse la charge de la preuve : seules dix fonctions sont autorisées, count, sum, avg, min, max, lower, upper, coalesce, date_trunc, now. Tout le reste est refusé par défaut, y compris une fonction légitime que personne n’a encore pensé à ajouter.

Une seule passe de validation ne suffit pas toujours. Un parcours structurel de l’arbre énumère ses points d’entrée un par un (projection, WHERE, JOIN, GROUP BY...), et il est facile d’en oublier un : une fonction interdite peut se cacher dans une clause FILTER (WHERE pg_sleep(10) IS NOT NULL), dans un ORDER BY intra-agrégat (sum(id ORDER BY pg_sleep(10))), dans WITHIN GROUP, DISTINCT ON, ou OFFSET.

services/aura-ai/src/nl2sql/mod.rs (tests)
RUST
#[test]
fn test_rejette_fonction_interdite_dans_filter() {
assert!(validate(
"SELECT count(*) FILTER (WHERE pg_sleep(10) IS NOT NULL) FROM users",
None, None
).is_err());
}

Le validateur d’Aurabase ajoute donc une seconde passe exhaustive, qui traverse toutes les expressions de l’arbre où qu’elles se trouvent, indépendamment du parcours structurel. C’est une défense en profondeur assumée : si la première passe rate un cas, la seconde le rattrape.

#
Étape 4

Borner les lignes retournées : LIMIT obligatoire et plafonné

SELECT * reste autorisé, il est utile pour l’exploration de données. Le risque n’est pas l’étoile, c’est l’absence de plafond sur une requête écrite par un modèle : une question mal formulée peut ramener une table entière, avec le coût mémoire et le temps de réponse que ça implique.

Aurabase applique une règle simple et transparente. Si le SQL généré ne porte pas de LIMIT, le serveur en ajoute un (100 lignes par défaut, valeur annoncée au modèle dans le prompt système). Si le SQL demande un LIMIT au-delà d’un plafond dur (1000 lignes par défaut), la requête est refusée explicitement plutôt que silencieusement réduite. Les deux valeurs sont configurables côté serveur (AI_NL2SQL_DEFAULT_LIMIT, AI_NL2SQL_MAX_LIMIT), et le serveur refuse même de démarrer si le défaut dépasse le plafond.

réponse /v1/ai/{project_id}/nl2sql (extrait)
JSON
{
"sql": "SELECT count(*) FROM orders WHERE status = 'paid' LIMIT 100",
"limit": 100,
"limit_injected": true
}
Info
Refuser plutôt que raboter en silence a un intérêt direct : un plafond appliqué sans le dire donnerait à l’appelant l’illusion que sa demande a été honorée, alors que le résultat aurait été tronqué sans qu’il le sache. limit_injected dit toujours si la valeur vient du modèle ou du serveur.
#
Étape 5

Verrouiller l’accès au schéma : catalogue système et cross-schema

Deux fuites distinctes menacent un moteur NL2SQL branché sur une base réelle : l’accès au catalogue système de Postgres, et l’accès à un schéma qui n’appartient pas à l’appelant. Les deux se bloquent à la validation, indépendamment de toute policy RLS posée en aval.

pg_catalog fait toujours partie du search_path, ce qui veut dire qu’un nom non qualifié comme pg_authid ou pg_stat_activity y accède directement, sans préfixe. Le validateur d’Aurabase bloque tout nom commençant par pg_, ainsi que information_schema et le schéma interne aura_console, qu’ils soient qualifiés ou non.

Sur un nom à deux composants (schema.table), seul le schéma du projet appelant est autorisé, toute autre valeur est rejetée. Un nom à trois composants ou plus est refusé d’office. Cette frontière au niveau de la requête générée s’ajoute à l’isolation au niveau base détaillée dans notre article sur l’isolation multi-tenant : l’une empêche le SQL généré de viser un autre schéma, l’autre empêche la connexion elle-même d’atteindre une autre base. Aucune des deux ne remplace l’autre.

#
Étape 6

Ne jamais laisser le client redéfinir le schéma interrogé

Un piège discret guette toute API NL2SQL qui accepte un paramètre décrivant le schéma ou les tables autorisées dans la requête du client. Si ce même paramètre sert à construire le prompt et à valider le SQL en sortie, un appelant peut mentir sur ce qui est autorisé, et la validation valide alors contre ce mensonge plutôt que contre la réalité de la base.

Aurabase introspecte le schéma réel de la base du projet à chaque appel, avec un court cache de trente secondes pour la performance, et rejette explicitement (erreur 400) tout champ schema, allowed_schema ou schema_context envoyé dans le corps de la requête, plutôt que de l’accepter puis de l’écraser en silence. La différence compte : un champ accepté puis ignoré donne l’illusion d’un contrôle qui n’existe pas ; un champ refusé le dit tout de suite.

#
Checklist

Auditer votre propre pipeline NL2SQL avant la production

Que vous utilisiez Aurabase ou que vous construisiez votre propre pipeline au-dessus d’un LLM générique, les points suivants couvrent ce qui manque le plus souvent.

Si vous écrivez le validateur vous-même

  • Parsez le SQL avec un vrai analyseur syntaxique pour votre dialecte exact, jamais avec une recherche de motifs dans une chaîne.
  • Adoptez un rejet par défaut : tout type de nœud, toute fonction non explicitement autorisée doit être refusé, pas seulement les cas dangereux déjà identifiés.
  • N’acceptez qu’un seul statement par requête, c’est le rejet le plus simple contre l’empilement de requêtes.
  • Testez le validateur avec des cas adversariaux réels (fonction interdite dans FILTER, dans un ORDER BY intra-agrégat, dans OFFSET), pas seulement avec des cas évidents.
  • Exécutez malgré tout le SQL validé avec un rôle Postgres à privilèges réduits sur le schéma attendu : le validateur borne la forme de la requête, le rôle borne ce qu’elle peut physiquement atteindre si un cas vous a échappé.

Si vous évaluez un framework NL2SQL tiers

  • Demandez explicitement si la validation est structurelle (AST) ou seulement une consigne de prompt : la réponse change tout.
  • Vérifiez qu’un plafond de lignes est appliqué par défaut, pas seulement documenté comme bonne pratique à votre charge.
  • Vérifiez si le schéma utilisé pour la validation peut être fourni par le client de l’API, ce qui rouvrirait exactement la faille décrite plus haut.
  • Comparez plusieurs outils sur ce critère précis avant de choisir : notre comparatif des outils NL2SQL détaille ce qui distingue les approches disponibles en 2026.
#
Défense en profondeur

Le validateur réduit le risque, il ne remplace pas la RLS

Un validateur AST solide réduit le risque à la source : le SQL qui atteint votre base a déjà une forme connue et bornée. Il ne remplace pas pour autant les policies RLS sur vos tables sensibles, qui décident quelles lignes un utilisateur donné a le droit de voir. Les deux couches répondent à des questions différentes : le validateur borne la forme de la requête générée, la RLS borne les données qu’elle peut retourner pour un utilisateur précis. Gardez les deux actives, même si l’une vous semble redondante avec l’autre.

Le NL2SQL couvre les questions structurées sur vos tables. Pour des questions sur du contenu non structuré, documents, notes, tickets, le RAG natif d’Aurabase suit une logique de sécurisation comparable, détaillée dans notre tutoriel pipeline RAG sur pgvector.

#
Questions Fréquentes

FAQ

Le prompt engineering est-il complètement inutile pour sécuriser le NL2SQL ?+
Non, il reste utile pour orienter le modèle vers un SQL correct et pertinent la majorité du temps. Mais ce n’est pas un contrôle de sécurité : une question ambiguë ou manipulée peut le contourner, et une consigne de prompt ne bloque physiquement rien. Un validateur structurel après génération reste nécessaire, quelle que soit la qualité du prompt.
Faut-il quand même activer la RLS si le SQL généré est déjà validé et restreint à SELECT ?+
Oui. Le validateur borne la forme de la requête (pas de sous-requête, pas de fonction hors liste blanche, LIMIT plafonné), pas les droits métier sur les données. La RLS reste la couche qui décide quelles lignes un utilisateur précis a le droit de voir, à l’intérieur même d’un SELECT parfaitement valide.
Une liste blanche fermée de dix fonctions ne limite-t-elle pas trop les questions possibles ?+
Oui, et c’est volontaire. Elle couvre l’essentiel de l’analytique en lecture (count, sum, avg, min, max, lower, upper, coalesce, date_trunc, now) et refuse tout le reste par défaut, y compris une fonction légitime que personne n’a encore pensé à ajouter. Chaque ajout doit être une décision explicite, pas un oubli dans une liste noire.
SÉCURITÉ NL2SQL

Testez le validateur sur votre propre schéma.

Un projet Aurabase suffit : la validation AST et le plafond de lignes sont déjà en place.

Créer un projet Documentation IA
Aucune carte bancaire requise · 500 MB gratuits · 50 000 MAU