flawopen.com/sql-injection/Python
●
CWE-89 · 緊急
セキュリティ研究 · FlawOpen
Python の SQL インジェクション(psycopg2・SQLAlchemy・Django)
cursor.execute() の中で f-string や % を使うと、リクエストパラメータが SQL を書き換え、1 人のユーザーを探す処理がテーブル全件の流出に変わります(CWE-89)。Python の各データベース API での安全な呼び出し方と、プレースホルダーでは扱えない 2 つのケース(カラム名と IN リスト)を解説します。[1], [2], [3], [4], [5], [6], [7], [9]
💡 わかりやすい解説 (ELI5)
郵便物の仕分け台に、「ラベルが ___ の荷物を仕分ける」という固定の指示カードがあるとします。作業員が読み上げる前に差出人のラベルをカードへ書き込むと、余分な文言まで新しい仕分け指示になり得ます。Python の f-string や % 書式化は SQL に対して同じことをします。ドライバーに値が届く前に、ひとつのコマンド文字列を組み立てます。execute(sql, params) は指示カードを固定したまま、ラベルをデータとして別に渡します。列名は指示の一部なので、コード側で定義した許可リストから選びます。[10], [1], [2], [9]
主要な概念と専門用語
cursor.execute(sql, params)- DB-API 2.0(PEP 249)で定められた呼び出しです。ドライバーは
paramsの値を SQL 文とは別に送るため、値が文の構造を変えることはありません。安全なのは値を第 2 引数に渡したときだけで、第 1 引数で f-string や%式を使うことこそが脆弱性です。[2], [3] paramstyle: %s, ?, :name- 各ドライバーは
paramstyle属性でプレースホルダーの形式を宣言しています。psycopg2 と PyMySQL は%sまたは%(name)s、sqlite3 は?または:nameです。%sを解釈するのはドライバーであって Python の%演算子ではないので、文字列を自分でフォーマットしてはいけません。[2], [3], [5] SQLAlchemy text()- SQLAlchemy で生の SQL 文字列を包む関数です。
:emailのような名前付きバインドパラメータを使い、値を辞書でexecute()に渡せば安全です。渡す文字列がすでに f-string なら危険です。text()は中身を一切エスケープしません。[6], [9] Django raw() / RawSQL / extra()- Django の ORM を迂回する手段です。
raw(sql, params)とRawSQL(sql, params)は、すべての値をparamsに渡した場合にだけ安全です。sqlに埋め込んだ内容は Django がそのまま送信します。Django のドキュメントはextra()を最後の手段としているので、レビューで禁止しましょう。[7], [9]
フレームワーク対応
- このガイドで説明する弱点の CWE 参照: CWE-89
- OWASP Top 10:2025 — A05:2025 Injection
ステップ・バイ・ステップの攻撃フロー
ステップ 1
ステップ 2
ステップ 3
ソースコード比較:脆弱 vs 堅牢化
脆弱な実装
# repository.py: 以下のクエリはどれもリクエストのデータを SQL 文に埋め込んでいる
import sqlite3
from sqlalchemy import text
def get_user(pg_cursor, user_id):
# psycopg2: ドライバーが見る前に f-string が文を組み立ててしまう
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: この % は Python の文字列フォーマットで、ドライバーのプレースホルダーではない
return conn.execute("SELECT id FROM orders WHERE status = '%s'" % status).fetchall()
def find_by_email(session, email):
# SQLAlchemy: text() は文字列の中身をエスケープしない
return session.execute(text(f"SELECT id FROM users WHERE email = '{email}'")).all()
def find_users(User, username):
# Django: raw() は埋め込まれた値をそのままデータベースに送る
return User.objects.raw(f"SELECT * FROM app_user WHERE username = '{username}'")
def get_orders_by_ids(session, ids):
# IN リスト: 連結した文字列だと要素に余分な SQL を紛れ込ませられる
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):
# 識別子: ソート列をクエリ文字列から直接受け取っている
pg_cursor.execute(f"SELECT id, email FROM users ORDER BY {sort_column}")
return pg_cursor.fetchall()
堅牢化されたセキュアパッチ
# repository.py: 値はバインドパラメータで渡し、名前は許可リストから取る
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 はドライバーのプレースホルダー。値は第 2 引数に渡す
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 は qmark 形式: ? プレースホルダーと、タプルで渡す値
return conn.execute("SELECT id FROM orders WHERE status = ?", (status,)).fetchall()
def find_by_email(session, email: str):
# SQLAlchemy: 名前付きバインドパラメータ。値は 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() は params を別送する。ORM のフィルターならさらに簡単
return User.objects.raw("SELECT * FROM app_user WHERE username = %s", [username])
def get_orders_by_ids(session, ids: list[int]):
# IN リスト: 展開可能なバインドが要素ごとにプレースホルダーを生成する
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):
# 識別子: まず許可リストで確認し、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()
エンジニアリング&システム堅牢化チェックリスト
- ✓SQL 引数が f-string、
%でフォーマットした文字列、str.format()呼び出し、+による連結のいずれかであるexecute()、executemany()、text()、raw()、RawSQL()はすべてブロックする。CI で Bandit B608 または Ruff S608 を有効にし、該当箇所でビルドを失敗させる。[8], [14], [1], [9] - ✓すべての値はドライバーのパラメータ引数から、そのドライバー固有の paramstyle で渡す。psycopg2 は
%s、sqlite3 は?、SQLAlchemy のtext()は:name。[2], [3], [5] - ✓テーブル名・カラム名・ソート方向は、コードに固定した許可リストからのみ取り、
sql.Identifierでクォートする。リクエストから直接読み取らない。[4], [9] - ✓IN リストは要素ごとに 1 つのパラメータでバインドする(展開可能な
bindparam、= ANY(%s)、または生成した?)。連結した文字列にはしない。[6], [3], [5] - ✓Django では ORM のフィルターを優先する。プルリクエスト内の
raw()、RawSQL、connection.cursor()はすべて params 引数を渡すこと。新たなextra()呼び出しは却下する。[7], [8], [9] - ✓アプリケーションに必要な権限だけを持つデータベースロールで接続する(DDL なし、スーパーユーザーなし)。すり抜けたインジェクションがあっても、テーブルの削除や他スキーマの読み取りはできなくなる。[9]
参考資料
- 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