SQLインジェクション対策の基本は、SQLを文字列連結で組み立てず、プレースホルダー(バインド変数)で値を渡すことです。プレースホルダーを使うと、利用者が入力した値は「SQLの文」ではなく「ただの値」として扱われるため、入力に記号が含まれていてもSQLの意味が変わりません。

この記事は、実装する開発者向けです。Python(sqlite3・psycopg)とNode.js(pg)で、脆弱なコードと修正版を実際にローカルのDBに対して実行し、同じ入力で「全件取得される」→「0件になる」ことを実行結果で比較します。さらに、プレースホルダーが使えないORDER BYの列名、LIKEのワイルドカード、IN句、ORMの生SQLなど、現場でつまずきやすい場面ごとの書き方と、AIが書いたコードを確認するポイントまで紹介します。

SQLインジェクション対策の早見表

場面 対策 位置づけ
WHEREやINSERTの値 プレースホルダー(?・%s・$1) 根本的な対策
ORDER BYの列名・テーブル名 ホワイトリストで決めた値だけを使う 根本的な対策
LIKEの検索語 プレースホルダー+%・_のエスケープ 意図しない検索を防ぐ
IN句 配列を1つのパラメータで渡す(= ANY($1))か、個数分のプレースホルダー 根本的な対策
ORMの生SQL raw・nativeでもパラメータ機能を使う 根本的な対策
エラー表示 DBのエラー文を画面に出さない 被害を小さくする備え
DBアカウント 必要最小限の権限だけを付与 被害を小さくする備え
WAF・入力チェック 補助として併用 コードの対策の代わりにはならない

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「SQLインジェクション対策|脆弱なコードと修正版を実行して比較」

SQLインジェクションとは:入力がSQLの一部になってしまう

SQLインジェクションとは、入力した値がSQLの「文」として解釈され、開発者の意図と違うSQLが実行されてしまう脆弱性です。原因はほぼ1つで、入力をSQLの文字列に直接つなげていることです。

名前で会員を検索する処理を例にします。

入力 組み立てられるSQL 結果
sato ... WHERE name = 'sato' satoだけ
x' OR '1'='1 ... WHERE name = 'x' OR '1'='1' 条件が常に真になり、全件

入力に含まれる ' が文字列の終わりとして解釈され、その後ろの OR '1'='1' がSQLの条件として動いてしまいます。IPA(情報処理推進機構)の「安全なウェブサイトの作り方」でも、根本的解決として「SQL文の組み立ては全てプレースホルダで実装する」ことが最初に挙げられています。

flowchart LR
  A["入力: x' OR '1'='1"] --> B{"SQLの組み立て方"}
  B -->|文字列連結| C["SQLの文の一部になる<br/>→ 条件が変わり全件"]
  B -->|プレースホルダー| D["ただの値として比較<br/>→ 一致なし 0件"]

実行して確認:脆弱なコードと修正版

以下はすべて、自分の手元のDB(検証用に作ったテーブル)に対して実行した結果です。確認は必ず自分が管理する環境で行ってください。

Python(sqlite3)

import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")
conn.executemany("INSERT INTO users(name, email) VALUES (?, ?)",
                 [("sato", "sato@example.com"), ("suzuki", "suzuki@example.com"),
                  ("tanaka", "tanaka@example.com")])

def find_user_unsafe(name):
    sql = f"SELECT id, name, email FROM users WHERE name = '{name}'"  # 文字列をつなげている
    print("SQL:", sql)
    return conn.execute(sql).fetchall()

def find_user_safe(name):
    sql = "SELECT id, name, email FROM users WHERE name = ?"  # プレースホルダー
    return conn.execute(sql, (name,)).fetchall()

attack = "x' OR '1'='1"
print("unsafe:", find_user_unsafe(attack))
print("safe  :", find_user_safe(attack))
print("safe  :", find_user_safe("sato"))

実行結果:

SQL: SELECT id, name, email FROM users WHERE name = 'x' OR '1'='1'
unsafe: [(1, 'sato', 'sato@example.com'), (2, 'suzuki', 'suzuki@example.com'), (3, 'tanaka', 'tanaka@example.com')]
safe  : []
safe  : [(1, 'sato', 'sato@example.com')]

文字列連結の版は、1人を探すはずが全員のメールアドレスを返しました。プレースホルダーの版は、x' OR '1'='1 という名前の会員を探すので0件です。普通の検索(sato)は、どちらでも同じように動きます。

Python(psycopg 3 × PostgreSQL 16)

import psycopg

conn = psycopg.connect("host=127.0.0.1 dbname=t03 user=t03app password=...")

def find_unsafe(name):
    return conn.execute(f"SELECT id, name FROM app_users WHERE name = '{name}'").fetchall()

def find_safe(name):
    return conn.execute("SELECT id, name FROM app_users WHERE name = %s", (name,)).fetchall()

attack = "x' OR '1'='1"
print("unsafe:", find_unsafe(attack))
print("safe  :", find_safe(attack))
unsafe: [(1, 'sato'), (2, 'suzuki'), (3, 'tanaka'), (4, 'yamada')]
safe  : []

psycopgの %s は、Pythonの文字列フォーマットの %s とは別物です。"... = '%s'" % name のように自分で埋め込むと文字列連結と同じになるため、必ず execute の第2引数で渡します。

Node.js(pg)

const { Client } = require('pg');

// 以下は async 関数の中で実行
const client = new Client({ host: '127.0.0.1', database: 't03', user: 't03app', password: '...' });
await client.connect();
const name = "x' OR '1'='1";

// 脆弱:テンプレートリテラルで連結
const unsafe = await client.query(`SELECT id, name FROM app_users WHERE name = '${name}'`);
console.log('unsafe:', unsafe.rows);

// 安全:$1 プレースホルダー
const safe = await client.query('SELECT id, name FROM app_users WHERE name = $1', [name]);
console.log('safe  :', safe.rows);
unsafe: [
  { id: 1, name: 'sato' },
  { id: 2, name: 'suzuki' },
  { id: 3, name: 'tanaka' },
  { id: 4, name: 'yamada' }
]
safe  : []

JavaScriptでは、テンプレートリテラル(`...${name}...`)が文字列連結と同じ働きをします。見た目がすっきりしているぶん気づきにくいので、SQLの中に ${ があれば要確認です。

なお、パラメータ付きのクエリに ; で2つ目の文を続けると、pgは次のエラーで実行しませんでした。

ERR   : cannot insert multiple commands into a prepared statement

Java(JDBC PreparedStatement)

Javaでは PreparedStatement と ? を使います。以下はJava 21でコンパイルを確認したコードです(検証環境にJDBCドライバを用意できなかったため、DBへの接続は実行していません。公式のJDBC APIに沿った書き方です)。

public List<String> findByName(Connection conn, String name) throws SQLException {
    // × String sql = "SELECT id, name FROM app_users WHERE name = '" + name + "'";
    String sql = "SELECT id, name FROM app_users WHERE name = ?";
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        ps.setString(1, name);                 // 1番目の ? に値をセット
        try (ResultSet rs = ps.executeQuery()) {
            List<String> result = new ArrayList<>();
            while (rs.next()) {
                result.add(rs.getInt("id") + ":" + rs.getString("name"));
            }
            return result;
        }
    }
}

PreparedStatement を使っていても、prepareStatement("... '" + name + "'") のように連結した文字列を渡すと効果がありません。値は必ず setString・setInt などでセットします。

プレースホルダーの書き方一覧

言語・ライブラリ 書き方 値の渡し方
Python sqlite3 ? または :name execute(sql, (値,))
Python psycopg %s または %(name)s execute(sql, (値,))
Node.js pg $1、$2 … query(sql, [値])
Java JDBC ? ps.setString(1, 値)
PHP PDO ? または :name $stmt->execute([値])

プレースホルダーが使えない場所:ORDER BYの列名

プレースホルダーで渡せるのは「値」だけで、列名・テーブル名・ASC/DESCのようなSQLの構造は渡せません。psycopgで試すと、エラーにはならず、意図どおりにも並びませんでした。

conn.execute("SELECT id, name FROM app_users ORDER BY %s", ("name",)).fetchall()
[(1, 'sato'), (2, 'suzuki'), (3, 'tanaka'), (4, 'yamada')]

'name' という文字列の定数で並べ替えたことになり、名前順にはなっていません(たまたまid順に見えています)。ここで列名を文字列連結すると、SQLインジェクションの入口になります。

列名は、ホワイトリスト(許可する値の一覧)から選びます。

from psycopg import sql

SORTABLE = {"name": "name", "created": "created_at", "id": "id"}  # 画面の値 → 列名

def list_users(sort_key, desc):
    col = SORTABLE.get(sort_key, "id")          # 一覧にない値は既定の列に
    direction = sql.SQL("DESC") if desc else sql.SQL("ASC")
    q = sql.SQL("SELECT id, name FROM app_users ORDER BY {} {}").format(sql.Identifier(col), direction)
    print(q.as_string(conn))
    return conn.execute(q).fetchall()

print(list_users("name", True))
print(list_users("name; DROP TABLE app_users", False))
SELECT id, name FROM app_users ORDER BY "name" DESC
[(4, 'yamada'), (3, 'tanaka'), (2, 'suzuki'), (1, 'sato')]
SELECT id, name FROM app_users ORDER BY "id" ASC
[(1, 'sato'), (2, 'suzuki'), (3, 'tanaka'), (4, 'yamada')]

一覧にない値は既定の列(id)になり、入力の文字列がSQLに入ることはありません。sql.Identifier は識別子として正しく引用符で囲む、psycopgの機能です。Javaでも Map.of("name", "name", ...) から getOrDefault で列名を決める方法が使えます。

LIKEの検索語:% と _ をエスケープする

LIKEの値もプレースホルダーで渡しますが、それだけでは %(任意の文字列)と _(任意の1文字)がワイルドカードとして働きます。「_ を含むメモ」を探したつもりが全件になる例です。

def escape_like(s):
    return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")

def search_note(word, escape=True):
    w = escape_like(word) if escape else word
    return conn.execute(
        "SELECT name, note FROM app_users WHERE note LIKE %s ESCAPE '\\'",
        ("%" + w + "%",)).fetchall()
like '_' no escape: [('sato', '100%達成'), ('suzuki', 'メモなし'), ('tanaka', 'a_b テスト'), ('yamada', '要確認')]
like '_' escaped  : [('tanaka', 'a_b テスト')]
like '%' no escape: [('sato', '100%達成'), ('suzuki', 'メモなし'), ('tanaka', 'a_b テスト'), ('yamada', '要確認')]
like '%' escaped  : [('sato', '100%達成')]

エスケープしないと、_ や % の1文字で全件がヒットしました。SQLの文が変わるわけではありませんが、意図しない大量の検索結果や、重い検索の原因になります。エスケープ文字(ここでは \)自体も先にエスケープするのがポイントです。

IN句:配列を1つのパラメータで渡す

IN句の値を ",".join(ids) で連結するのはよくある書き方ですが、これも文字列連結です。PostgreSQLでは、配列を1つのパラメータで渡して = ANY(...) を使うのが簡単です。

conn.execute("SELECT id, name FROM app_users WHERE id = ANY(%s) ORDER BY id", ([1, 3],)).fetchall()
# → [(1, 'sato'), (3, 'tanaka')]
await client.query('SELECT id, name FROM app_users WHERE id = ANY($1::int[]) ORDER BY id', [[2, 4]]);
// → [ { id: 2, name: 'suzuki' }, { id: 4, name: 'yamada' } ]

上の2つはどちらも実行して確認した結果です。配列を渡せないDBやライブラリでは、値の個数分だけプレースホルダーを並べる(IN (?, ?, ?) を件数から組み立てる)方法を使います。組み立てるのは ? の個数だけで、値そのものは連結しません。

ORMの生SQL・ストアドプロシージャ

ORMを使っていても、生SQLを書く機能で文字列を連結すると、SQLインジェクションは起こります。

仕組み 危ない書き方 安全な書き方
SQLAlchemy text(f"... WHERE name = '{name}'") text("... WHERE name = :name") + {"name": name}
Django raw(f"... '{name}'")、extra() に連結 raw("... = %s", [name])
JPA createNativeQuery("... '" + name + "'") createNativeQuery("... = ?1").setParameter(1, name)
MyBatis ${name}(そのまま埋め込み) #{name}(プレースホルダー)

MyBatisの ${} と #{} は1文字違いで意味が大きく変わります。${} はORDER BYの列名など、ホワイトリストで検査した値にだけ使います。

ストアドプロシージャの中で動的SQLを組み立てる場合も同じです。PostgreSQLのPL/pgSQLでは、値は EXECUTE ... USING で渡し、識別子は format('%I', ...) で引用符付きにします。

EXECUTE format('SELECT count(*) FROM %I WHERE name = $1', table_name)
  INTO cnt
  USING user_name;

この形の関数を作って確認すると、user_name に x' OR '1'='1 を渡しても0件、table_name に app_users; DROP TABLE app_users を渡すと、全体が1つのテーブル名として扱われ relation "app_users; DROP TABLE app_users" does not exist というエラーになりました。値は USING、識別子は %I と覚えておくと安心です。

被害を小さくする対策:エラー表示と最小権限

DBのエラー文を画面に出さない

o'brien のように ' を含む名前を、文字列連結の版に渡すと次のエラーになりました。

ERR: SyntaxError syntax error at or near "brien"
LINE 1: SELECT id, name FROM app_users WHERE name = 'o'brien'
                                                       ^

このエラー文には、組み立てたSQLそのものが含まれています。画面やAPIのレスポンスにそのまま返すと、テーブル名や列名などの手がかりを渡すことになります。詳細はサーバーのログにだけ出し、利用者には「エラーが発生しました」のような一般的なメッセージを返します。なお、プレースホルダーの版では o'brien もただの値として扱われ、エラーにならずに検索できました。

DBアカウントに最小限の権限だけを与える

アプリ用のアカウント(t03app)に、app_users の SELECT権限だけ を付けて、DELETEとDROPを試しました。

GRANT SELECT ON app_users TO t03app;
ERR: InsufficientPrivilege permission denied for table app_users
ERR: InsufficientPrivilege must be owner of table app_users

万一SQLインジェクションがあっても、権限にない操作は実行できません。参照だけの画面は参照用のアカウント、テーブルの作成や変更はマイグレーション用のアカウント、と分けておくと被害を小さくできます。

AIが書いたコードのチェックポイント

生成AIが書いたSQL周りのコードは、動作は正しくても、文字列連結になっていることがあります。レビューでは次の点を確認します。

確認すること 見つけ方の例
SQLの中に変数を埋め込んでいないか f"SELECT、`SELECT ... ${、"SELECT ..." + を検索
ORDER BY・列名・テーブル名が入力から来ていないか 並べ替え・絞り込み機能の引数をたどる
LIKEの %・_ をエスケープしているか LIKE を検索
IN句を join で組み立てていないか join( とSQLが近くにないか
ORMのraw・native・${} を使っていないか raw(、text(、createNativeQuery、${ を検索
例外のメッセージをそのまま返していないか catch の中で e.message・str(e) をレスポンスにしていないか

AIに頼むときは、「SQLはすべてプレースホルダーで書いて。列名はホワイトリストで決めて」と最初に条件として伝えると、この形のコードが出てきやすくなります。

よくある質問

Q. 入力値のチェック(バリデーション)をしていれば、プレースホルダーは不要ですか? A. 必要です。入力チェックは、想定外の値を早めに弾く補助として有効ですが、SQLの組み立て方そのものの対策にはなりません。名前やメモのように ' を含みうる項目もあるため、プレースホルダーを基本にします。

Q. WAFを入れれば対策は十分ですか? A. WAFは、既知の攻撃パターンを入口で止める補助として役立ちます。ただし、アプリのコードに脆弱性が残っている状態は変わらないため、コードでプレースホルダーを使うことが根本的な対策です。

Q. 自分で ' をエスケープする関数を作れば安全ですか? A. DBの種類や文字コード、設定によってエスケープの規則が異なるため、自作は取りこぼしが起きやすくなります。DBのドライバが用意するプレースホルダーを使うのが確実です。

Q. 数値のIDなら文字列連結でも大丈夫ですか? A. 数値のつもりでも、リクエストから来た値は文字列として届くことがあります。型の変換をしたうえで、値はプレースホルダーで渡すと、例外を考えずに済みます。

まとめ

  • SQLインジェクション対策の基本は、文字列連結をやめてプレースホルダーで値を渡すこと
  • 実行結果では、連結版は「' OR '1'='1」で全件、プレースホルダー版は0件になった
  • ORDER BYの列名はプレースホルダーにできない。ホワイトリストで決める
  • LIKEは %・_・エスケープ文字をエスケープ、IN句は 配列を1つのパラメータで渡す
  • ORMの生SQL・MyBatisの ${}・ストアドの動的SQLでも同じ考え方で書く
  • エラー文を画面に出さない、DBアカウントは最小権限で被害を小さくする
  • AIが書いたコードは、SQL周りの文字列連結がないかを確認する

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「SQLインジェクション対策|脆弱なコードと修正版を実行して比較」

株式会社RJCでは、動くだけでなく安心して使えるシステムを届けるために、安全な書き方をチームで確かめ合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?