- はじめに — エスケープ関数を探しているなら方向が違います
- エスケープではなくバインディングである理由 — パーサレベルの分離
- プリペアドステートメントはサーバーでどう処理されるのか
- ORM を使っても破られる三つの箇所
- バインドできないもの — 識別子、ORDER BY、IN、LIKE
- 二次インジェクションと NoSQL インジェクション — 同じ原理、違う表面
- 多層防御 — 最小権限アカウントと検知
- おわりに — execute 呼び出しの第二引数を見てください
はじめに — エスケープ関数を探しているなら方向が違います
SQL インジェクション関連のコードレビューで最もよく見かける修正提案はこういう形です。「クォートをエスケープしてください」「特殊文字をフィルタリングしてください」「シングルクォートを二重にしてください」。検索結果の上位にも相変わらずエスケープ関数とブラックリスト正規表現が並びます。
このアプローチは原理的に負ける戦いです。エスケープは値を文字列リテラルの中で安全にしようとする試みであり、その前提が崩れるケースが出続けるからです。次のコードはエスケープをきちんとしているのに破られます。
# 数値コンテキストにはエスケープすべきクォートがない
order_id = request.args["id"] # "1 OR 1=1"
cur.execute("SELECT * FROM orders WHERE id = " + escape(order_id))
SELECT * FROM orders WHERE id = 1 OR 1=1
エスケープ関数はクォートとバックスラッシュを扱いますが、値がクォートなしで入る位置では仕事がありません。LIMIT 句、ソート方向、真偽値の比較、そして文字セットがずれた状況でも同じ問題が起きます。
正解は別の層にあります。値を安全な文字列にする代わりに、値が SQL テキストにそもそも入らないようにすることです。この記事ではその構造が実際にどう動くのか、そして ORM を使えば自動的に解決されるという通念がどこで崩れるのかを扱います。
エスケープではなくバインディングである理由 — パーサレベルの分離
データベースがクエリを処理する過程は大きくパース、プラン作成、実行に分かれます。インジェクションはパース段階の問題です。ユーザー入力が SQL テキストの一部になると、パーサはそれを文法要素として解釈します。パーサには「この部分はユーザーが入れたもの」という情報がありません。
バインディングはこの順序をひっくり返します。まずプレースホルダの入った SQL をパーサに渡して構文木を確定させ、値はそのあとに別のメッセージとして渡します。値が届くときにはすでに文法が決まっているので、値の中に何が入っていても構造を変えられません。
脆弱なコードと安全なコードを並べると違いが明確です。
import psycopg
email = request.args["email"]
# 脆弱: 値が SQL テキストになる
with conn.cursor() as cur:
cur.execute(f"SELECT id, email, role FROM users WHERE email = '{email}'")
# 安全: 値は別チャネルを通る
with conn.cursor() as cur:
cur.execute("SELECT id, email, role FROM users WHERE email = %s", (email,))
ここで必ず押さえるべきなのが %s の正体です。これは Python の文字列フォーマットではなく DB-API ドライバのプレースホルダです。ですから次の二行はまったく別のコードです。
cur.execute("SELECT * FROM users WHERE email = %s", (email,)) # バインディング
cur.execute("SELECT * FROM users WHERE email = %s" % email) # 文字列フォーマット、脆弱
二行目は Python が先に文字列を完成させてしまうので、ドライバにはすでに組み立て済みの SQL しか届きません。タイプミス一文字の差で脆弱性になります。コードレビューでは execute 呼び出しに第二引数があるかを確認するのが最も速い検査です。
JavaScript も同じです。
// 脆弱: テンプレートリテラルで組み立てる
const { rows } = await pool.query(
`SELECT id, email FROM users WHERE email = '` + req.query.email + `'`
)
// 安全: プレースホルダと値の配列
const { rows } = await pool.query('SELECT id, email FROM users WHERE email = $1', [
req.query.email,
])
バインディングがエスケープより優れている理由を一文でまとめるとこうです。エスケープは値が安全であることを開発者が証明しなければならないのに対し、バインディングは値がコードになる経路そのものをなくします。前者は入力のあらゆるケースを考慮しなければならず、後者は考慮すべきことがありません。
プリペアドステートメントはサーバーでどう処理されるのか
バインディングが本当に値を分離しているのか確認してみます。PostgreSQL の拡張クエリプロトコルは Parse、Bind、Execute の三段階に分かれています。明示的に再現できます。
PREPARE user_by_email (text) AS
SELECT id, email, role FROM users WHERE email = $1;
EXECUTE user_by_email ('alice@example.com');
では攻撃文字列をそのまま入れてみます。
EXECUTE user_by_email ('nobody@example.com'' OR ''1''=''1');
id | email | role
----+-------+------
(0 rows)
行が返ってきません。OR 1=1 が条件として解釈されず、メールアドレスの値の一部として扱われたからです。サーバーログを有効にしておけば、何が起きたのかが正確に見えます。
psql -c "ALTER SYSTEM SET log_statement = 'all'" -c "SELECT pg_reload_conf()"
tail -f /var/log/postgresql/postgresql-16-main.log
LOG: execute user_by_email: SELECT id, email, role FROM users WHERE email = $1
DETAIL: parameters: $1 = 'nobody@example.com'' OR ''1''=''1'
クエリテキストとパラメータが別々の行に記録されます。これがバインディングの本質です。サーバーが受け取った SQL テキストにはユーザー入力がありません。
ここでよく見落とされる落とし穴が一つあります。すべてのドライバが実際にサーバープリペアを使うわけではありません。一部のドライバはクライアント側で値をエスケープして SQL を組み立て、単純クエリとして送ります。これをエミュレーションと呼びます。
// PHP PDO のデフォルトはドライバによってはエミュレーションになりうる
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_EMULATE_PREPARES => false, // サーバープリペアを強制
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
# MySQL JDBC: サーバープリペアを使うには明示する必要がある
jdbc:mysql://db:3306/app?useServerPrepStmts=true&cachePrepStmts=true
エミュレーションがすなわち脆弱性というわけではありません。実装が正しければ安全です。ただし安全性の根拠が「パーサが分離した」から「ドライバのエスケープが正確である」へと降りてきます。かつての MySQL の GBK 系文字セットでエスケープを回避する手法が知られていたのも、この層の問題でした。選べるのならサーバープリペアを有効にするほうが良いです。
ORM を使っても破られる三つの箇所
「ORM を使っているからインジェクションは気にしなくていい」という言い方は半分しか正しくありません。ORM のクエリビルダ API を通る値はバインドされますが、ORM は必ず生の SQL へ降りる扉を開けておきます。そして実務のコードはその扉をよく使います。
一つ目の箇所は生クエリです。
from sqlalchemy import text
# 脆弱: text() は文字列をそのまま SQL にする
result = session.execute(
text(f"SELECT * FROM orders WHERE status = '{status}' ORDER BY created_at DESC")
)
# 安全: text() にもバインディングのプレースホルダがある
result = session.execute(
text("SELECT * FROM orders WHERE status = :status ORDER BY created_at DESC"),
{"status": status},
)
text() を使ったという事実そのものは問題ではありません。その中で f-string を使ったことが問題です。この区別がコードレビューの中心的な基準になります。
Django も同じです。
# 脆弱
User.objects.raw("SELECT * FROM auth_user WHERE username = '%s'" % username)
User.objects.extra(where=[f"last_login > '{since}'"])
# 安全
User.objects.raw("SELECT * FROM auth_user WHERE username = %s", [username])
User.objects.filter(last_login__gt=since)
Django の extra() はドキュメントでも使用を推奨していない API です。コードベースで extra(、RawSQL(、.raw( を検索してみると、たいてい何か所か出てきます。
rg -n --type py '\.extra\(|RawSQL\(|\.raw\(' src/
src/reports/views.py:88: qs = Order.objects.extra(where=[f"total_amount > {threshold}"])
src/admin/search.py:41: rows = User.objects.raw("SELECT * FROM auth_user WHERE email LIKE '%%%s%%'" % q)
二つ目の箇所は条件節を文字列で組み立てるパターンです。検索フィルタが複数ある画面で特によく見られます。
// 脆弱: knex を使いながら条件だけ文字列で貼り付ける
let query = knex('orders')
if (req.query.status) {
query = query.whereRaw(`status = '${req.query.status}'`)
}
if (req.query.minAmount) {
query = query.whereRaw(`total_amount >= ${req.query.minAmount}`)
}
// 安全: ビルダ API または whereRaw のバインディング引数
let query = knex('orders')
if (req.query.status) {
query = query.where('status', req.query.status)
}
if (req.query.minAmount) {
query = query.whereRaw('total_amount >= ?', [Number(req.query.minAmount)])
}
Sequelize も同様です。sequelize.query には必ず replacements または bind を使います。
// 安全
const rows = await sequelize.query(
'SELECT id, email FROM users WHERE tenant_id = :tenantId AND email = :email',
{ replacements: { tenantId, email }, type: QueryTypes.SELECT }
)
三つ目の箇所は ORM のフィルタ条件をクライアント入力で直接埋めるパターンです。これは文法上インジェクションではありませんが、結果は同じです。
// 脆弱: リクエストボディがそのまま where 節になる
const users = await User.findAll({ where: req.body.filter })
クライアントが filter に別テナントの条件や演算子を入れると、意図しないデータが出てきます。入力はスキーマで検証したうえで明示的にマッピングしなければなりません。
const schema = z.object({
status: z.enum(['pending', 'paid', 'refunded']).optional(),
minAmount: z.coerce.number().min(0).optional(),
})
const filter = schema.parse(req.body)
バインドできないもの — 識別子、ORDER BY、IN、LIKE
ここが実務で最もよく詰まる箇所です。バインディングは値にしかできません。構文構造を決める位置にはプレースホルダを使えません。
-- 動作しない。カラム名が文字列リテラルとして解釈される
SELECT * FROM orders ORDER BY $1;
ERROR: cannot use column reference in ORDER BY with a parameter
ソートカラムをユーザーに選ばせたいなら、許可リスト方式しかありません。入力を検査するのではなく、入力をあらかじめ決めておいた安全な SQL 断片に置き換えるのです。
SORT_COLUMNS = {
"created": "o.created_at",
"amount": "o.total_amount",
"status": "o.status",
}
SORT_DIRECTIONS = {"asc": "ASC", "desc": "DESC"}
def build_order_by(sort_key: str, direction: str) -> str:
column = SORT_COLUMNS.get(sort_key, "o.created_at")
order = SORT_DIRECTIONS.get(direction.lower(), "DESC")
return f"ORDER BY {column} {order}"
sql = f"SELECT o.id, o.total_amount FROM orders o WHERE o.tenant_id = %s {build_order_by(sort, dir)} LIMIT %s"
cur.execute(sql, (tenant_id, limit))
SORT_COLUMNS にない入力は静かにデフォルト値へ落ちます。ユーザー入力が SQL に入るのではなく辞書のキーとしてのみ使われるので、どんな文字列が来ても結果は三つの安全な値のどれかです。
動的にテーブル名やカラム名を使う必要があるなら、ドライバの識別子引用 API を使います。文字列で囲まないでください。
from psycopg import sql
cur.execute(
sql.SQL("SELECT * FROM {} WHERE tenant_id = %s").format(sql.Identifier(table_name)),
(tenant_id,),
)
PostgreSQL の関数の中で動的 SQL を作るときも同じ原則があります。
-- %I は識別子として、%L はリテラルとして安全に引用する
EXECUTE format('REFRESH MATERIALIZED VIEW %I', target_view);
EXECUTE format('SELECT count(*) FROM orders WHERE status = %L', status_value);
IN リストはプレースホルダの個数が可変なので混乱しがちです。正攻法が二つあります。
# 方法 1: プレースホルダを個数分だけ生成する(値は依然としてバインドされる)
placeholders = ", ".join(["%s"] * len(ids))
cur.execute(f"SELECT * FROM orders WHERE id IN ({placeholders})", tuple(ids))
# 方法 2: 配列を一つバインドする(PostgreSQL)
cur.execute("SELECT * FROM orders WHERE id = ANY(%s)", (list(ids),))
方法 2 のほうが優れています。プレースホルダの個数が変わらないので実行プランのキャッシュが再利用され、リストの長さに上限を設けるのも簡単です。
LIKE 句にはインジェクションとは別の落とし穴があります。バインディングをきちんとしても、ユーザーが入れた % や _ がワイルドカードとして動作します。
# バインドはできているが、ユーザーが "%" だけ入力すると全行が返る
cur.execute("SELECT * FROM users WHERE name LIKE %s", (f"%{q}%",))
これはセキュリティ事故にもなります。全テーブルスキャンを誘発してサービス拒否につながったり、意図した検索範囲を越えて他のデータを露出させたりします。ワイルドカードはエスケープしなければなりません。
def like_escape(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
cur.execute(
r"SELECT * FROM users WHERE name LIKE %s ESCAPE '\'",
(f"%{like_escape(q)}%",),
)
まとめると、インジェクション表面は位置ごとに違う扱いをしなければなりません。
| 位置 | バインド可否 | 正しい処理 | ありがちな誤答 |
|---|---|---|---|
| WHERE 句の値 | 可能 | プレースホルダでバインド | エスケープ後の文字列連結 |
| IN リスト | 可能 | 配列バインドまたはプレースホルダ生成 | カンマで join した文字列を挿入 |
| LIKE パターン | 可能 | バインドとワイルドカードエスケープと ESCAPE 句 | バインドだけしてパーセント記号を放置 |
| LIMIT、OFFSET | 可能 | 整数変換してからバインド | 数値だから安全だとそのまま連結 |
| ORDER BY のカラム | 不可 | 許可リストで安全な SQL 断片にマッピング | 正規表現で特殊文字をフィルタリング |
| ソート方向 | 不可 | 二値のマッピングテーブル | 文字列を大文字にしてそのまま挿入 |
| テーブル、スキーマ、カラム名 | 不可 | 識別子引用 API または許可リスト | ダブルクォートで囲む |
| 保存後に再利用される値 | 状況による | 使用時点でも改めてバインド | 入力時点の検証だけを信頼 |
| MongoDB のフィルタオブジェクト | 該当なし | スキーマ検証のうえで型を固定 | リクエストボディをそのまま渡す |
二次インジェクションと NoSQL インジェクション — 同じ原理、違う表面
入力検証だけでは捕まえられない類型が二次インジェクションです。保存は安全にしたのに、あとで別のコードがその値を文字列として組み立てるケースです。
# 登録時点: バインディングで安全に保存する
cur.execute("INSERT INTO users (username, email) VALUES (%s, %s)", (username, email))
username に report'; DROP TABLE audit_log; -- のような値が入っても、この時点では何も起きません。ただの文字列です。問題は数日後に実行されるバッチです。
# 夜間バッチ: ユーザー別のビューを作る
for row in cur.execute("SELECT username FROM users").fetchall():
name = row[0]
cur.execute(f"CREATE OR REPLACE VIEW report_{name} AS SELECT * FROM orders WHERE owner = '{name}'")
ここで爆発します。データベースから読んだ値だからと信頼したことが原因です。二次インジェクションが危険な理由は、攻撃の流れが二つのコードベースにまたがっているのでレビューで見えないこと、そしてログを見てもバッチジョブが自分で勝手におかしな SQL を実行したように見えることです。
原則は単純です。値の出所がどこであれ、SQL に入れるときは常にバインドします。「うちの DB から来た値だから」というのは信頼の根拠になりません。レポートビュー名のように識別子として使う必要があるなら、ユーザーの文字列ではなくユーザー ID のような内部の整数を使います。
NoSQL も原理は同じです。文法が SQL でないだけで、ユーザー入力がクエリの構造を変えうるという点は同一です。
// 脆弱: リクエストボディの値がそのままフィルタになる
app.post('/login', async (req, res) => {
const user = await db.collection('users').findOne({
email: req.body.email,
password: req.body.password,
})
if (user) return res.json({ token: issueToken(user) })
return res.status(401).json({ error: 'invalid credentials' })
})
JSON ボディは文字列だけを載せるわけではありません。オブジェクトも載せられます。
curl -X POST https://api.example.com/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":{"$ne":null}}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
パスワードを知らなくてもログインできます。ドライバは値ではなく演算子オブジェクトを受け取り、それをクエリ演算子として解釈しました。フォームエンコードのリクエストでも角括弧の表記でネストしたオブジェクトを作れるので、同じことが起こります。
防御は型を固定することです。
import { z } from 'zod'
const LoginBody = z.object({
email: z.string().email().max(254),
password: z.string().min(8).max(200),
})
app.post('/login', async (req, res) => {
const parsed = LoginBody.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'invalid payload' })
const { email, password } = parsed.data
const user = await db.collection('users').findOne({ email })
if (!user || !(await argon2.verify(user.passwordHash, password))) {
return res.status(401).json({ error: 'invalid credentials' })
}
return res.json({ token: issueToken(user) })
})
同じ理由でサーバー側の JavaScript 評価機能は無効にしておくのが良いです。MongoDB の where 演算子や集計パイプラインでの関数実行は、入力がコードになる典型的な経路です。
多層防御 — 最小権限アカウントと検知
バインディングを徹底しても、コードベースのどこかに抜けがあると仮定しなければなりません。そのとき被害範囲を決めるのがデータベースアカウントの権限です。
アプリケーションのアカウントが所有者権限やスーパーユーザーなら、インジェクション一件がスキーマ全体の削除につながります。逆に必要なテーブルへの DML 権限しかなければ、同じインジェクションはデータの参照にとどまります。
-- アプリケーション専用ロール: DDL なし、所有権なし
CREATE ROLE app_rw LOGIN PASSWORD 'set-by-secret-manager';
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT USAGE ON SCHEMA app TO app_rw;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_rw;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA app TO app_rw;
-- 暴走するクエリを止める
ALTER ROLE app_rw SET statement_timeout = '5s';
ALTER ROLE app_rw SET idle_in_transaction_session_timeout = '30s';
レポートや管理画面のように読み取りだけの経路はアカウントを分けます。
CREATE ROLE app_ro LOGIN PASSWORD 'set-by-secret-manager';
GRANT USAGE ON SCHEMA app TO app_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_ro;
権限を確認する習慣も必要です。
SELECT grantee, table_name, string_agg(privilege_type, ', ' ORDER BY privilege_type) AS privs
FROM information_schema.role_table_grants
WHERE grantee IN ('app_rw', 'app_ro')
GROUP BY grantee, table_name
ORDER BY grantee, table_name;
grantee | table_name | privs
---------+------------+--------------------------------
app_ro | orders | SELECT
app_rw | orders | DELETE, INSERT, SELECT, UPDATE
app_rw | users | DELETE, INSERT, SELECT, UPDATE
検知は静的解析が最も費用対効果に優れます。文字列の組み立てがクエリ実行関数へ流れ込む経路をデータフローとして追跡します。
semgrep --config 'p/sql-injection' --config 'p/nosql-injection' src/
src/reports/views.py
88┆ qs = Order.objects.extra(where=[f"total_amount > {threshold}"])
⚠ python.django.security.injection.sql.sql-injection-extra
User-controlled data flows into a raw SQL clause.
src/api/search.js
34┆ knex.raw(`SELECT * FROM orders WHERE status = '${status}'`)
⚠ javascript.knex.security.knex-raw-sql-injection
CI で新しく追加された違反だけを失敗させれば、既存コードに足を取られません。
semgrep ci --config 'p/sql-injection' --baseline-commit "$(git merge-base origin/main HEAD)"
最後に、Web ファイアウォールについて一つだけ訂正します。WAF は UNION SELECT のような既知のパターンを止めますが、回避手法は出続けますし、正常な入力を遮断する誤検知も生みます。修正のデプロイまでの時間を稼ぐ緩和策であって対策ではありません。WAF ルールを追加してチケットを閉じるのが最も危険な締めくくりです。
おわりに — execute 呼び出しの第二引数を見てください
この記事の内容はコードレビューのルール三行に圧縮できます。
第一に、クエリ実行関数に値が別の引数として渡されているかを見ます。f-string、テンプレートリテラル、文字列の足し算、パーセントフォーマットがクエリ文字列の中にあれば、そこが脆弱性です。ORM を使っているという事実はこの検査を免除してくれません。
第二に、バインドできない位置は許可リストでのみ処理します。ORDER BY のカラム、ソート方向、テーブル名は検証ではなくマッピングです。ユーザー入力が SQL に入るのではなく、安全な値を選ぶためのキーとしてのみ使われるようにします。
第三に、値の出所を信頼の根拠にしません。データベースから読んだ値も、内部サービスがくれた値も、SQL に入れるときは同じようにバインドします。二次インジェクションはこの原則一つで消えます。
そしてこのすべてが失敗したときに備えて、アプリケーションのアカウントから DDL 権限を剥がしておいてください。インジェクションが発生したとき事故報告書に書かれる内容が「一部テーブルの参照」なのか「スキーマの削除」なのかは、コードではなく GRANT 文が決めます。
현재 단락 (1/241)
SQL インジェクション関連のコードレビューで最もよく見かける修正提案はこういう形です。「クォートをエスケープしてください」「特殊文字をフィルタリングしてください」「シングルクォートを二重にしてくださ...