flawopen.com/sql-injection/Python
SQL-Injection in Python verhindern (psycopg2, SQLAlchemy, Django)
Ein f-String oder ein % in cursor.execute() lässt einen Request-Parameter Ihr SQL umschreiben und macht aus der Abfrage eines einzelnen Nutzers einen Dump der ganzen Tabelle (CWE-89). Hier steht der sichere Aufruf für jede Python-Datenbank-API, dazu die zwei Fälle, die Platzhalter nicht abdecken: Spaltennamen und IN-Listen.[1], [2], [3], [4], [5], [6], [7], [9]
Stellen Sie sich eine Postsortierung mit einer festen Anweisungskarte vor: „Sortiere das Paket mit dem Etikett ___.“ Wird das Etikett des Absenders vor dem Lesen in die Anweisung kopiert, können zusätzliche Wörter zu neuen Sortierbefehlen werden. Ein Python-f-string oder %-Formatierung macht genau das mit SQL: Der gesamte Befehl entsteht, bevor der Treiber den Wert erhält. execute(sql, params) lässt die Anweisung unverändert und übermittelt das Etikett getrennt als Datenwert. Ein Spaltenname gehört zur Anweisung; wählen Sie ihn aus einer im Code festgelegten Allowlist.[10], [1], [2], [9]
Kernkonzepte & Begriffe
cursor.execute(sql, params)- Der Aufruf aus DB-API 2.0 (PEP 249). Der Treiber schickt die Werte aus
paramsgetrennt vom SQL-Text, sodass sie die Anweisung nie verändern können. Sicher ist das nur, wenn die Werte im zweiten Argument stehen; ein f-String oder ein%-Ausdruck im ersten Argument ist genau der Fehler.[2], [3] paramstyle: %s, ?, :name- Jeder Treiber gibt seinen Platzhalter im Attribut
paramstylean: psycopg2 und PyMySQL verwenden%soder%(name)s, sqlite3 verwendet?oder:name. Das%swertet der Treiber aus, nicht Pythons%-Operator – formatieren Sie den String also nie selbst.[2], [3], [5] SQLAlchemy text()- Kapselt einen rohen SQL-String für SQLAlchemy. Sicher, wenn Werte benannte Bind-Parameter wie
:emailsind und als Dict anexecute()gehen. Unsicher, wenn der übergebene String schon ein f-String ist:text()maskiert nichts von dem, was darin steht.[6], [9] Django raw() / RawSQL / extra()- Die Notausgänge aus Djangos ORM.
raw(sql, params)undRawSQL(sql, params)sind nur sicher, wenn jeder Wert inparamssteht; alles, was insqleingesetzt wird, schickt Django unverändert weiter. Die Django-Dokumentation nenntextra()ein letztes Mittel, also im Review verbieten.[7], [9] psycopg2.sql.Identifier- Platzhalter transportieren nur Werte, nie Tabellen- oder Spaltennamen.
sql.Identifiersetzt den Namen in doppelte Anführungszeichen, sodass er nicht aus dem Bezeichner ausbrechen kann. Prüfen Sie ihn vorher gegen eine Allowlist, damit Nutzer keine Spalten wählen, die Sie nicht freigeben wollten.[4], [9] IN lists and expanding binds- In sqlite3 lässt sich eine Python-Liste nicht an ein einzelnes
?binden, deshalb wird oft wieder verkettet. Verwenden Sie in SQLAlchemy einen expandierendenbindparam(ein Platzhalter pro Element), in psycopg2id = ANY(%s)mit einer Liste, oder erzeugen Sie für sqlite3 ein?pro Element.[6], [3], [5]
Framework-Zuordnungen
- CWE-Referenz für die in diesem Leitfaden beschriebene Schwachstelle: CWE-89
- OWASP Top 10:2025 — A05:2025 Injection
Schritt-für-Schritt Angriffsablauf
Der Endpunkt liefert jede Zeile
Die Bedingung ist für jede Zeile wahr, also liefert die Abfrage eines Nutzers die ganze Tabelle. psycopg2 akzeptiert außerdem mehrere durch Semikolon getrennte Anweisungen in einem Aufruf; sqlite3 lehnt sie ab, was den Schaden begrenzt, den Datendiebstahl aber nicht verhindert.[13], [5], [1], [9]
Quellcode: Verwundbar vs. Sicher
# repository.py: jede Abfrage unten fügt Request-Daten in den SQL-Text ein
import sqlite3
from sqlalchemy import text
def get_user(pg_cursor, user_id):
# psycopg2: der f-String baut die Anweisung, bevor der Treiber sie sieht
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: dieses % ist Pythons String-Formatierung, kein Treiber-Platzhalter
return conn.execute("SELECT id FROM orders WHERE status = '%s'" % status).fetchall()
def find_by_email(session, email):
# SQLAlchemy: text() maskiert nicht, was schon im String steht
return session.execute(text(f"SELECT id FROM users WHERE email = '{email}'")).all()
def find_users(User, username):
# Django: raw() schickt eingesetzte Werte unverändert an die Datenbank
return User.objects.raw(f"SELECT * FROM app_user WHERE username = '{username}'")
def get_orders_by_ids(session, ids):
# IN-Liste: zusammengefügte Strings lassen ein Element zusätzliches SQL mitbringen
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):
# Bezeichner: die Sortierspalte kommt direkt aus dem Query-String
pg_cursor.execute(f"SELECT id, email FROM users ORDER BY {sort_column}")
return pg_cursor.fetchall()
# repository.py: Werte reisen als gebundene Parameter, Namen kommen aus einer Allowlist
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 ist der Platzhalter des Treibers; der Wert steht im zweiten 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 nutzt den qmark-Stil: ?-Platzhalter, Werte in einem Tupel
return conn.execute("SELECT id FROM orders WHERE status = ?", (status,)).fetchall()
def find_by_email(session, email: str):
# SQLAlchemy: benannter Bind-Parameter, Wert erst beim execute()-Aufruf
stmt = text("SELECT id FROM users WHERE email = :email")
return session.execute(stmt, {"email": email}).all()
def find_users(User, username: str):
# Django: raw() schickt params getrennt; ein ORM-Filter ist noch einfacher
return User.objects.raw("SELECT * FROM app_user WHERE username = %s", [username])
def get_orders_by_ids(session, ids: list[int]):
# IN-Liste: ein expandierender Bind erzeugt einen Platzhalter pro Element
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):
# Bezeichner: erst Allowlist, dann mit sql.Identifier quoten
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()
Checkliste für Engineering & Systemsicherheit
- ✓Jeden Aufruf von
execute(),executemany(),text(),raw()oderRawSQL()blockieren, dessen SQL-Argument ein f-String, ein mit%formatierter String, einstr.format()-Aufruf oder eine+-Verkettung ist. Bandit B608 oder Ruff S608 im CI aktivieren, damit der Build daran scheitert.[8], [14], [1], [9] - ✓Jeden Wert über das Parameter-Argument des Treibers übergeben, in dessen eigenem paramstyle:
%sbei psycopg2,?bei sqlite3,:namebeitext()in SQLAlchemy.[2], [3], [5] - ✓Tabellennamen, Spaltennamen und Sortierrichtungen nur aus einer fest im Code hinterlegten Allowlist nehmen und mit
sql.Identifierquoten. Nie direkt aus dem Request lesen.[4], [9] - ✓IN-Listen mit einem Parameter pro Element binden (expandierender
bindparam,= ANY(%s)oder erzeugte?-Marker), nie als zusammengefügten String.[6], [3], [5] - ✓In Django ORM-Filter bevorzugen. Jedes
raw(),RawSQLoderconnection.cursor()in einem Pull Request muss ein params-Argument übergeben, neueextra()-Aufrufe werden abgelehnt.[7], [8], [9] - ✓Mit einer Datenbankrolle verbinden, die nur die Rechte hat, die die Anwendung braucht (kein DDL, kein Superuser), damit eine durchgerutschte Injection keine Tabellen löschen und keine anderen Schemas lesen kann.[9]
Quellen
- 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