SQLインジェクションは「文字列を組み立てて作る」バグ
SQLインジェクションが起きるのは、ユーザーが入力した値がそのままSQL文の一部としてデータベースに渡され、パースされてしまうときです。本来は「データ」として扱われるはずの値が「構文」として実行された瞬間に、ユーザーはあなたと同じ権限で何でもできるようになってしまいます。
下のコードは典型的なアンチパターンで、どの言語でも書けてしまうような擬似コードです。
-- これはやってはいけません
query = "SELECT * FROM users WHERE name = '" + user_input + "'"
user_input が Ada なら普通の検索になります。ところが user_input が ' OR 1=1 -- だとこうなります:
SELECT * FROM users WHERE name = '' OR 1=1 --'
-- で末尾のクォートがコメントアウトされ、OR 1=1 が全行にマッチするため、攻撃者にユーザーテーブルを丸ごと抜かれてしまいます。さらに悪いパターンでは、; で文を区切って2つ目のSQL文をつなぎ、テーブルをDROPしたり、データを抜き取ったり、管理者アカウントを新規作成したりといったことまでやられます。
この脆弱性はSQLite側の問題ではありません。問題はそのSQL文字列を組み立てているコード側にあります。
SQLインジェクション対策の本命:パラメータ化クエリ
パラメータ化クエリでは、SQL文と値をはっきり分けて扱います。SQL側には ? や :name といったプレースホルダだけを書いておき、値は別の引数として渡す形です。SQLiteはSQLを一度だけパースしてコンパイルし、そのコンパイル済みプランに対して値をバインドします。こうすることで、渡した値がSQLの一部として解釈されることはなくなります。
それでは、先ほどと同じ「危なそうな」検索処理を、安全なやり方で書き直してみましょう。
SQLiteのシェルでは値を直接打ち込みますが、アプリのコードで同じことをやる場合は次のようになります(Pythonのsqlite3ドライバの例)。
# Python — パラメータ化、安全
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))
SQL文と値のタプルを、別々の引数として渡してください。ドライバはこの2つをSQLiteに別々に送り込みます。たとえ user_input が ' OR 1=1 -- であっても、SQLiteは文字どおり ' OR 1=1 -- という名前のユーザーを探しに行き、当然見つからないので何も返ってきません。
ここでいう「安全」とは何なのか
ここでの安全性は、文字列のパターンマッチでもエスケープ処理でもありません。構造そのもので守られています。SQLiteは値を受け取る前に、SQL文を内部表現にコンパイルしてしまうのです。
-- コンパイル済みステートメントには文字列ではなくスロットがあります。
SELECT * FROM users WHERE name = ?
^
プレースホルダースロット
値をバインドすると、その値は型付きのデータ(TEXT、INTEGER、BLOB など)としてプレースホルダに収まります。SQLite はこれを SQL として再解析することはありません。パーサーの仕事はすでに終わっているので、攻撃者が割り込んで SQL を注入する余地そのものが存在しないのです。
これが、エスケープ処理よりもパラメータ化クエリのほうが圧倒的に信頼できる理由です。エスケープは危険な文字を文字列から取り除こうとする後処理ですが、バインド変数を使えばそもそも危険な文字列を組み立てません。
文字列フォーマットで SQL を組み立てない
どの言語にも手軽な近道があります。Python の f-string、JavaScript のテンプレートリテラル、Java の String.format ── どれも便利ですが、SQL に対して使うと一気に地雷化します。
# ダメな例 — f文字列は値をSQLテキストに埋め込んでしまう
cursor.execute(f"SELECT * FROM users WHERE name = '{user_input}'")
# ダメな例 — % フォーマットも同じ問題
cursor.execute("SELECT * FROM users WHERE name = '%s'" % user_input)
# 良い例 — プレースホルダー + values 引数
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))
最初のふたつは、ドライバが見るより前にユーザー入力をSQL文字列に埋め込んでしまっています。SQLiteがクエリを受け取った時点で、もう手遅れです。3つ目だけが、SQLと値をきちんと別レーンに分けています。
ルールはシンプルです。値が入る場所で +、f-string、format、テンプレートリテラルを使ってSQL文字列を組み立てたくなったら、いったん手を止めて、代わりにプレースホルダを使ってください。
複数パラメータと名前付きプレースホルダ
実際のクエリでは、値がひとつだけということはまずありません。SQLiteでは位置指定の ? と名前付きの :name、どちらのプレースホルダも使えます。
アプリケーションコードに落とし込むと、次のようになります。
# 位置パラメータ
cursor.execute(
"SELECT * FROM orders WHERE customer = ? AND status = ?",
("Ada", "paid"),
)
# 名前付き — パラメータが複数ある場合に分かりやすい
cursor.execute(
"SELECT * FROM orders WHERE total > :min_total AND status = :status",
{"min_total": 50, "status": "paid"},
)
名前付きプレースホルダはスケールしやすいのが強みです。値が3〜4個を超えてくると、?, ?, ?, ? はどれがどれだか分からない当てっこゲームになりますが、:customer, :total, :status, :created_at なら見ただけで意味が伝わります。
識別子は別のアプローチが必要
バインド変数が使えるのは 値 の部分だけです。つまり = の右側、IN (...) の中、VALUES (...) の中といった箇所ですね。テーブル名やカラム名、ASC/DESC のようなSQLキーワードに対しては使えません。
-- これは動作しません。プレースホルダーはカラム名の代わりになりません。
SELECT * FROM users ORDER BY ? ASC
動的な識別子をどうしても使いたい場合 ── たとえば、ユーザーに並び替え対象のカラムを選ばせたいときなどは、SQL を組み立てる前に必ずホワイトリストで照合してください。
# 許可リスト方式
ALLOWED_SORT_COLUMNS = {"name", "created_at", "role"}
if sort_column not in ALLOWED_SORT_COLUMNS:
raise ValueError(f"無効なソートカラム: {sort_column}")
query = f"SELECT * FROM users ORDER BY {sort_column} ASC"
cursor.execute(query)
ユーザーから受け取った文字列は、SQL に渡る前に、安全だと分かっている値のリストと突き合わせて検証しています。sort_column がハードコードされた 3 つの名前のいずれかにしかなり得ないからこそ、ここで f-string を使っても問題ないわけです。
SQLインジェクションの実例と、その防ぎ方
実際に悪意のある入力を投げ込んで、ダメな書き方と安全な書き方を並べて見てみましょう。まずは小さな users テーブルを用意します。
脆弱な書き方では全ユーザーが返ってきますが、パラメータ化したクエリでは ' OR 1=1 -- という名前のユーザーを文字どおり探しに行くので、結果は空っぽです。同じ入力でも結果がまったく違うのは、後者では値が「SQLとして解釈されなかった」からです。
SQLインジェクション対策チェックリスト
- 自分でハードコードした値以外、つまりユーザー入力・リクエストボディ・環境変数など外部から来るすべての値には、必ず
?か:nameプレースホルダを使う。 - 値の埋め込みに
+連結・f-string・formatを使わない。 - テーブル名やカラム名を動的に切り替える場合は、クエリに差し込む前に固定のホワイトリスト(allowlist)で検証する。
- ドライバを信頼する。自前のエスケープ関数を書かない。バインド変数の仕組みは歴史も実績も比べものにならないほど積み上がっていて、そっちが正解。
- チーム内のクエリレビューでは、「ユーザー入力がSQL文字列に連結されていないか?」 という観点だけは必ずチェックする。連結していたら直す。
この感覚が指先に染みつけば、SQLインジェクションはもう「いちいち気にするバグ」ではなくなります。
次は: アプリケーションからの接続
安全なクエリの形 — SQL側にはプレースホルダ、値は別で渡す — はもう掴めたはずです。次のページでは、Python・Node.js などの実際のアプリケーションコードからSQLiteに接続する流れを見ていきます。コネクション管理や、パラメータ化クエリが普段のリクエスト処理のどこに収まるのかも一緒に解説します。
よくある質問
SQLiteもSQLインジェクションの影響を受けますか?
受けます。クエリを文字列連結で組み立てている限り、SQLiteも他のRDBMSと同じく無防備です。これはSQLite側の設定で直る話ではなく、アプリ側で値の渡し方を変えるしかありません。? か :name のプレースホルダを使えば、ドライバが安全に処理してくれます。
プレースホルダを使うとなぜインジェクションを防げるのですか?
? などのプレースホルダを使うと、SQLiteはまずSQL文だけを解析・コンパイルし、後から値を「データ」として既にコンパイル済みのスロットに流し込みます。つまり、渡した値がSQL構文として解釈される余地がそもそもありません。攻撃者が抜け出せる文字列が存在しない、というイメージです。
クォートをエスケープする自前処理ではダメですか?
やめておきましょう。手動エスケープは穴だらけになりがちで、Unicodeのクォート、文字コードを使った小細工、コメント記号など、必ずどこかで漏れが出ます。ドライバが ? や :name を提供しているのは、まさにそれを考えなくて済むようにするためです。「絶対に安全な値」と思っていても、毎回プレースホルダを使うのが鉄則です。
テーブル名やカラム名をユーザー入力から決めたい場合は?
バインドパラメータが使えるのは「値」だけで、識別子(テーブル名・カラム名)には使えません。どうしても動的に決めたい場合は、許可リスト(allowlist)であらかじめ有効な名前だけを定義しておき、それと一致するか検証してからSQLに埋め込みます。生のユーザー入力をそのまま format で差し込むのは絶対NGです。