Menu

SQLite : éviter l'injection SQL avec des requêtes paramétrées

Pourquoi concaténer des chaînes pour construire ses requêtes est une catastrophe, comment fonctionne réellement une injection SQL, et comment les requêtes paramétrées de SQLite la rendent impossible.

Cette page contient des éditeurs exécutables — modifiez, exécutez et voyez la sortie instantanément.

L'injection SQL, c'est un bug de concaténation de chaînes

L'injection SQL survient quand une saisie utilisateur finit par faire partie du texte SQL que votre base de données analyse. Dès que cette frontière devient floue — dès qu'une valeur tapée par l'utilisateur se transforme en syntaxe exécutée par la base — l'utilisateur peut faire tout ce que vous pouvez faire.

Voici l'anti-pattern classique, en pseudo-code que n'importe quel langage peut produire :

-- À NE PAS FAIRE
query = "SELECT * FROM users WHERE name = '" + user_input + "'"

Si user_input vaut Ada, la requête se comporte normalement. En revanche, si user_input vaut ' OR 1=1 --, vous obtenez :

SELECT * FROM users WHERE name = '' OR 1=1 --'

Le -- met en commentaire le guillemet final, OR 1=1 matche toutes les lignes, et l'attaquant vient tout simplement de siphonner votre table d'utilisateurs. Les variantes plus vicieuses enchaînent ; avec une seconde requête pour supprimer des tables, exfiltrer des données ou insérer un nouveau compte admin.

La faille ne vient pas de SQLite. Elle vient du code qui a construit cette chaîne.

Requêtes paramétrées SQLite : la vraie solution

Une requête paramétrée sépare le texte SQL des valeurs. Le SQL contient des placeholders — ? ou :name — et vous transmettez les valeurs à part. SQLite parse et compile le SQL une seule fois, puis lie (bind) vos valeurs dans le plan déjà compilé. Les valeurs ne peuvent jamais devenir du SQL.

Voici comment exécuter une recherche en apparence vulnérable, mais de façon sûre :

Dans le shell SQLite, on saisit directement la valeur, mais côté code applicatif, l'équivalent ressemble à ceci (avec le driver sqlite3 de Python) :

# Python — paramétré, sûr
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))

Passez la requête SQL et le tuple de valeurs comme deux arguments distincts. Le pilote les transmet séparément à SQLite. Même si user_input vaut ' OR 1=1 --, SQLite va chercher un utilisateur dont le nom est littéralement ' OR 1=1 --, et bien sûr n'en trouve aucun.

Ce que « sûr » signifie vraiment ici

La sécurité ne repose ni sur du pattern-matching, ni sur de l'échappement. Elle est structurelle. SQLite compile la requête sous une forme interne avant même de voir votre valeur :

-- L'instruction compilée contient un emplacement, pas une chaîne.
SELECT * FROM users WHERE name = ?
                                 ^
                                 emplacement de paramètre

Quand vous liez une valeur, elle se glisse dans l'emplacement comme une donnée typée — TEXT, INTEGER, BLOB, peu importe. SQLite ne la repasse jamais à l'analyseur SQL. Il n'y a tout simplement aucune syntaxe par laquelle l'attaquant pourrait injecter quoi que ce soit, puisque le parseur a déjà terminé son travail.

Voilà pourquoi les requêtes paramétrées sont fiables là où l'échappement ne le sera jamais. Échapper, c'est tenter de nettoyer les caractères dangereux d'une chaîne. Lier les paramètres, c'est ne jamais construire cette chaîne dangereuse au départ.

Évitez le formatage de chaînes

Chaque langage a son raccourci tentant — les f-strings en Python, les template literals en JavaScript, String.format en Java — et tous, sans exception, sont des pièges quand on parle de SQL.

# À NE PAS FAIRE — la f-string interpole la valeur dans le texte SQL
cursor.execute(f"SELECT * FROM users WHERE name = '{user_input}'")

# À NE PAS FAIRE — même problème, formatage avec %
cursor.execute("SELECT * FROM users WHERE name = '%s'" % user_input)

# À FAIRE — placeholder + argument values
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))

Les deux premières approches injectent l'entrée utilisateur dans la chaîne SQL avant même que le driver n'y ait accès. Quand SQLite reçoit la requête, le mal est déjà fait. La troisième, elle, garde le SQL et la valeur sur deux voies bien séparées.

La règle est mécanique : dès que vous vous surprenez à construire une requête SQL avec +, des f-strings, format ou des template literals à l'endroit où devrait se trouver une valeur — stop, utilisez un placeholder à la place.

Plusieurs paramètres et placeholders nommés

En pratique, une requête contient rarement une seule valeur. SQLite accepte à la fois les placeholders positionnels ? et les placeholders nommés :nom :

En pratique, dans le code, voilà ce que ça donne :

# Positionnel
cursor.execute(
    "SELECT * FROM orders WHERE customer = ? AND status = ?",
    ("Ada", "paid"),
)

# Nommé — plus clair lorsqu'il y a plusieurs paramètres
cursor.execute(
    "SELECT * FROM orders WHERE total > :min_total AND status = :status",
    {"min_total": 50, "status": "paid"},
)

Les paramètres nommés passent mieux à l'échelle. Au-delà de trois ou quatre valeurs, ?, ?, ?, ? devient un vrai casse-tête : qui correspond à quoi ? Avec :customer, :total, :status, :created_at, le code se lit tout seul.

Les identifiants demandent une autre approche

Les paramètres liés ne fonctionnent que pour les valeurs — autrement dit, ce qui se trouve après le =, à l'intérieur d'un IN (...) ou d'un VALUES (...). Ils ne marchent pas pour les noms de tables, les noms de colonnes, ni pour les mots-clés SQL comme ASC ou DESC.

-- Ceci NE fonctionne PAS. Le paramètre ne peut pas remplacer un nom de colonne.
SELECT * FROM users ORDER BY ? ASC

Si vous avez besoin d'un identifiant dynamique — par exemple, laisser l'utilisateur choisir la colonne de tri — valide-le contre une liste blanche avant de construire la requête SQL :

# Approche par liste blanche
ALLOWED_SORT_COLUMNS = {"name", "created_at", "role"}

if sort_column not in ALLOWED_SORT_COLUMNS:
    raise ValueError(f"Colonne de tri invalide : {sort_column}")

query = f"SELECT * FROM users ORDER BY {sort_column} ASC"
cursor.execute(query)

La chaîne fournie par l'utilisateur est validée par rapport à un ensemble fixe de valeurs sûres avant d'approcher la moindre requête SQL. La f-string reste tolérable ici uniquement parce que sort_column ne peut désormais prendre qu'une des trois valeurs codées en dur.

Exemple concret d'injection SQL neutralisée

Comparons les deux versions côte à côte avec une entrée malveillante. Commençons par créer une petite table users :

Le formulaire vulnérable renvoie tous les utilisateurs. La version avec requête paramétrée, elle, cherche un utilisateur dont le nom est littéralement ' OR 1=1 -- et ne renvoie rien. Même entrée, résultat radicalement différent — parce que dans le second cas, la valeur n'a jamais été interprétée comme du SQL.

Checklist rapide pour prévenir l'injection SQL

  • Utilisez des placeholders ? ou :nom pour chaque valeur qui vient de l'extérieur de votre code — saisie utilisateur, corps de requête HTTP, variables d'environnement, bref tout ce que vous n'avez pas écrit en dur.
  • Ne construisez jamais une requête SQL avec +, des f-strings ou format à l'endroit où une valeur doit aller.
  • Pour les noms de tables ou de colonnes dynamiques, validez l'entrée contre une liste blanche figée avant de l'injecter dans la requête.
  • Faites confiance au driver. N'écrivez pas votre propre fonction d'échappement de guillemets. Le mécanisme des paramètres liés est plus ancien, mieux éprouvé, et il est correct.
  • Lors d'une code review des requêtes de votre équipe, posez-vous une seule question : est-ce qu'une entrée utilisateur est concaténée dans du texte SQL ? Si oui, corrigez-la.

Une fois ce réflexe ancré dans vos doigts, l'injection SQL cesse d'être une catégorie de bug à laquelle vous devez encore penser.

Et après : se connecter depuis une application

Vous avez vu à quoi ressemble une requête sûre — placeholder dans le SQL, valeur passée à côté. La page suivante montre comment brancher concrètement SQLite depuis du vrai code applicatif en Python, Node.js et quelques autres, avec la gestion des connexions et la place des requêtes paramétrées dans le cycle d'une requête HTTP classique.

Questions fréquentes

SQLite est-il vulnérable à l'injection SQL ?

Oui, autant que n'importe quel autre moteur SQL dès lors que le code applicatif construit ses requêtes en concaténant des chaînes. La parade ne se trouve pas dans une option de SQLite : tout se joue dans la façon dont vous transmettez les valeurs depuis votre application. Utilisez des requêtes paramétrées avec des marqueurs ? ou :nom, et le pilote s'occupe du reste.

Comment les requêtes paramétrées empêchent-elles une injection SQL ?

Avec un marqueur comme ?, SQLite analyse et compile d'abord la requête, puis insère vos valeurs dans des emplacements prédéfinis de l'instruction déjà compilée. Ces valeurs ne peuvent jamais être interprétées comme du SQL : elles restent des données, point final. Il n'y a tout simplement plus de chaîne dont un attaquant pourrait s'échapper.

Je peux pas juste échapper les apostrophes dans les entrées utilisateur ?

Non. L'échappement manuel est fragile : tôt ou tard, vous oublierez un cas tordu (apostrophes Unicode, astuces d'encodage, marqueurs de commentaire) et vous expédierez une faille en production. Les pilotes proposent les paramètres ? et :nom justement pour vous éviter d'avoir à y penser. Utilisez-les systématiquement, même pour des valeurs que vous croyez « sûres ».

Et pour des noms de table ou de colonne issus de l'utilisateur ?

Les paramètres liés ne fonctionnent que pour les valeurs, pas pour les identifiants. Si un nom de table ou de colonne doit vraiment être dynamique, validez-le contre une liste blanche de noms connus avant de l'injecter dans le SQL. Ne passez jamais un identifiant fourni par l'utilisateur via un simple format ou une concaténation.

Coddy programming languages illustration

Apprendre à coder avec Coddy

COMMENCER