flawopen.com/sql-injection/Python
Injection SQL en Python (psycopg2, SQLAlchemy, Django)
Une f-string ou un % dans cursor.execute() laisse un paramètre de requête réécrire votre SQL et transforme la recherche d'un seul utilisateur en extraction de toute la table (CWE-89). Voici l'appel sûr pour chaque API de base de données Python, ainsi que les deux cas que les marqueurs ne couvrent pas : noms de colonnes et listes IN.[1], [2], [3], [4], [5], [6], [7], [9]
Imaginez un centre de tri postal avec une fiche d'instructions fixe : « Trier le colis portant l'étiquette ___ ». Si l'on recopie l'étiquette de l'expéditeur dans la fiche avant de la lire, des mots supplémentaires peuvent devenir de nouvelles consignes d'acheminement. Une f-string Python ou le formatage avec % fait cela au SQL : il construit une seule commande avant que le pilote reçoive la valeur. execute(sql, params) garde la fiche fixe et transmet l'étiquette séparément comme donnée. Un nom de colonne fait partie de la commande ; choisissez-le dans une liste d'autorisation définie par le code.[10], [1], [2], [9]
Concepts Clés et Termes
cursor.execute(sql, params)- L'appel de la DB-API 2.0 (PEP 249). Le pilote envoie les valeurs de
paramsséparément du texte SQL, elles ne peuvent donc jamais modifier la requête. L'appel n'est sûr que si les valeurs sont dans le second argument ; une f-string ou une expression%dans le premier argument, c'est précisément la faille.[2], [3] paramstyle: %s, ?, :name- Chaque pilote déclare son marqueur dans son attribut
paramstyle: psycopg2 et PyMySQL utilisent%sou%(name)s, sqlite3 utilise?ou:name. Le%sest lu par le pilote, pas par l'opérateur%de Python : ne formatez jamais la chaîne vous-même.[2], [3], [5] SQLAlchemy text()- Enveloppe une chaîne SQL brute pour SQLAlchemy. Sûr lorsque les valeurs sont des paramètres nommés comme
:email, passés àexecute()dans un dict. Dangereux si la chaîne fournie est déjà une f-string :text()n'échappe rien de ce qu'elle contient.[6], [9] Django raw() / RawSQL / extra()- Les portes de sortie de l'ORM de Django.
raw(sql, params)etRawSQL(sql, params)ne sont sûrs que si chaque valeur passe parparams; Django transmet tel quel tout ce qui est interpolé danssql. La documentation de Django qualifieextra()de dernier recours : interdisez-le en revue.[7], [9] psycopg2.sql.Identifier- Les marqueurs ne transportent que des valeurs, jamais des noms de table ou de colonne.
sql.Identifierentoure le nom de guillemets doubles pour qu'il ne puisse pas sortir de l'identifiant. Vérifiez-le d'abord contre une liste autorisée pour que l'utilisateur ne choisisse pas des colonnes que vous ne vouliez pas exposer.[4], [9] IN lists and expanding binds- Dans sqlite3, une liste Python ne peut pas être liée à un seul
?, d'où le retour à la concaténation. Utilisez unbindparamextensible dans SQLAlchemy (un marqueur par élément),id = ANY(%s)avec une liste dans psycopg2, ou générez un?par élément pour sqlite3.[6], [3], [5]
Correspondances de référentiels
- Référence CWE pour la faiblesse décrite dans ce guide : CWE-89
- OWASP Top 10:2025 — A05:2025 Injection
Déroulement de l'Attaque Étape par Étape
Le endpoint renvoie toutes les lignes
La condition est vraie pour chaque ligne : la recherche d'un utilisateur renvoie toute la table. psycopg2 accepte aussi plusieurs instructions séparées par des points-virgules en un seul appel ; sqlite3 les refuse, ce qui limite les dégâts sans empêcher le vol de données.[13], [5], [1], [9]
Code Source : Vulnérable vs Sécurisé
# repository.py : chaque requête ci-dessous colle des données de la requête HTTP dans le texte SQL
import sqlite3
from sqlalchemy import text
def get_user(pg_cursor, user_id):
# psycopg2 : la f-string construit l'instruction avant que le pilote ne la voie
pg_cursor.execute(f"SELECT id, email FROM users WHERE id = {user_id}")
return pg_cursor.fetchone()
def find_orders(conn: sqlite3.Connection, status):
# sqlite3 : ce % est un formatage de chaîne Python, pas un marqueur du pilote
return conn.execute("SELECT id FROM orders WHERE status = '%s'" % status).fetchall()
def find_by_email(session, email):
# SQLAlchemy : text() n'échappe pas ce qui est déjà dans la chaîne
return session.execute(text(f"SELECT id FROM users WHERE email = '{email}'")).all()
def find_users(User, username):
# Django : raw() envoie les valeurs interpolées telles quelles à la base
return User.objects.raw(f"SELECT * FROM app_user WHERE username = '{username}'")
def get_orders_by_ids(session, ids):
# liste IN : une chaîne jointe permet à un élément de transporter du SQL en plus
id_list = ", ".join(ids)
return session.execute(text(f"SELECT id FROM orders WHERE id IN ({id_list})")).all()
def list_users(pg_cursor, sort_column):
# identifiant : la colonne de tri vient directement de la query string
pg_cursor.execute(f"SELECT id, email FROM users ORDER BY {sort_column}")
return pg_cursor.fetchall()
# repository.py : les valeurs voyagent en paramètres liés, les noms viennent d'une liste autorisée
import sqlite3
from psycopg2 import sql
from sqlalchemy import bindparam, text
SORTABLE_COLUMNS = {"id", "email", "created_at"}
def get_user(pg_cursor, user_id: int):
# psycopg2 : %s est le marqueur du pilote ; la valeur va dans le second argument
pg_cursor.execute("SELECT id, email FROM users WHERE id = %s", (user_id,))
return pg_cursor.fetchone()
def find_orders(conn: sqlite3.Connection, status: str):
# sqlite3 utilise le style qmark : marqueurs ?, valeurs dans un tuple
return conn.execute("SELECT id FROM orders WHERE status = ?", (status,)).fetchall()
def find_by_email(session, email: str):
# SQLAlchemy : paramètre nommé, valeur fournie au moment de execute()
stmt = text("SELECT id FROM users WHERE email = :email")
return session.execute(stmt, {"email": email}).all()
def find_users(User, username: str):
# Django : raw() envoie les params à part ; un filtre de l'ORM est encore plus simple
return User.objects.raw("SELECT * FROM app_user WHERE username = %s", [username])
def get_orders_by_ids(session, ids: list[int]):
# liste IN : un bind extensible produit un marqueur par élément
stmt = text("SELECT id FROM orders WHERE id IN :ids").bindparams(
bindparam("ids", expanding=True)
)
return session.execute(stmt, {"ids": ids}).all()
def list_users(pg_cursor, sort_column: str):
# identifiant : d'abord la liste autorisée, puis les guillemets via sql.Identifier
if sort_column not in SORTABLE_COLUMNS:
raise ValueError(f"unsupported sort column: {sort_column!r}")
query = sql.SQL("SELECT id, email FROM users ORDER BY {}").format(sql.Identifier(sort_column))
pg_cursor.execute(query)
return pg_cursor.fetchall()
Liste de Contrôle de Sécurité pour l'Ingénierie
- ✓Bloquez tout
execute(),executemany(),text(),raw()ouRawSQL()dont l'argument SQL est une f-string, une chaîne formatée avec%, un appelstr.format()ou une concaténation+. Activez Bandit B608 ou Ruff S608 dans la CI pour faire échouer le build.[8], [14], [1], [9] - ✓Passez chaque valeur par l'argument de paramètres du pilote, avec son propre paramstyle :
%spour psycopg2,?pour sqlite3,:namepourtext()de SQLAlchemy.[2], [3], [5] - ✓Ne prenez les noms de table, les noms de colonne et les sens de tri que dans une liste autorisée codée en dur, et citez-les avec
sql.Identifier. Ne les lisez jamais directement dans la requête.[4], [9] - ✓Liez les listes IN avec un paramètre par élément (un
bindparamextensible,= ANY(%s)ou des marqueurs?générés), jamais sous forme de chaîne jointe.[6], [3], [5] - ✓Sous Django, préférez les filtres de l'ORM. Tout
raw(),RawSQLouconnection.cursor()dans une pull request doit passer un argument params, et les nouveaux appels àextra()sont refusés.[7], [8], [9] - ✓Connectez-vous avec un rôle de base de données limité aux privilèges dont l'application a besoin (pas de DDL, pas de superutilisateur), pour qu'une injection passée entre les mailles ne puisse ni supprimer de tables ni lire d'autres schémas.[9]
Sources
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command
- PEP 249 — Python Database API Specification v2.0 (paramstyle)
- psycopg2 — Passing parameters to SQL queries
- psycopg2.sql — SQL string composition
- Python sqlite3 — How to use placeholders to bind values in SQL queries
- SQLAlchemy — text() and bindparam()
- Django — Performing raw SQL queries
- Bandit B608: hardcoded_sql_expressions
- OWASP SQL Injection Prevention Cheat Sheet
- Python Language Reference — f-strings
- Flask Quickstart — request query arguments
- Django — HttpRequest.GET
- Psycopg — Multiple statements compared with psycopg2
- Ruff S608 — hardcoded SQL expression