はじめに
以下のようなコードを見かけたことはないでしょうか。
String sql = "SELECT * FROM users WHERE name = '" + name + "'";
「動くし、テストも通っているから問題ない」と思いがちですが、これはSQLインジェクションの温床になります。
この記事では、PreparedStatement を使った対策に絞って、「なぜ安全なのか」を仕組みから解説します。
まず、何が危ないのか
こんなログイン処理を考えます。
// NG:ユーザー入力をそのまま連結している
String sql = "SELECT * FROM users WHERE name = '" + name + "' AND password = '" + password + "'";
name に以下の文字列を入力されたとします。
' OR '1'='1
SQLはこうなります。
SELECT * FROM users WHERE name = '' OR '1'='1' AND password = ''
'1'='1' は常にtrueなので、パスワードなしで全ユーザーの情報が取得できてしまいます。
なぜこうなるかというと、ユーザーが入力した文字列が「データ」ではなく「SQL命令の一部」として解釈されてしまうからです。この「データと命令の混在」こそがSQLインジェクションの本質です。
PreparedStatementとは
PreparedStatementは、SQL文の構造を先に確定させてから、値を後からバインドする仕組みです。
// OK:PreparedStatementを使う
String sql = "SELECT * FROM users WHERE name = ? AND password = ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setString(1, name);
ps.setString(2, password);
ResultSet rs = ps.executeQuery();
? の部分が「プレースホルダー」です。値は後からセットしますが、この時点でSQL文の構造はすでに確定しています。
なぜPreparedStatementは安全なのか
PreparedStatementが安全な理由は、値をバインドする際にJDBCドライバーが自動でエスケープ処理を行うからです。
先ほどの攻撃文字列 ' OR '1'='1 をバインドすると、内部では以下のように扱われます。
-- ドライバーがエスケープして、文字列リテラルとして安全に扱う
SELECT * FROM users WHERE name = '\' OR \'1\'=\'1' AND password = ''
シングルクォートがエスケープされるため、SQLの構造を壊すことができません。' OR '1'='1 はSQLとして解釈されず、ただの検索文字列として処理されます。
2段階で処理されることがポイント
PreparedStatementの内部処理は大きく2段階に分かれています。
① prepareStatement(sql)
→ DBサーバーにSQL文の構造を送信してコンパイルしてもらう
→ この時点でSQL文の「骨格」が確定する
② setString() / setInt() などで値をバインド
→ 値は「データ」としてのみ渡される
→ SQLの構造には一切影響しない
文字列連結との決定的な違いはここです。文字列連結は①と②が混在しているため、値がSQL命令として解釈される余地が生まれてしまいます。
Spring Boot(JPA)での書き方
実務でJPAを使っている場合も、根底にある考え方は同じです。
Spring Data JPAのメソッド名クエリ(最も安全)
public interface UserRepository extends JpaRepository<User, Long> {
// 内部でPreparedStatementが使われる
List<User> findByNameAndEmail(String name, String email);
}
メソッド名からクエリを自動生成する方式は、内部でプレースホルダーを使うため、SQLインジェクションを気にする必要がありません。
@Query アノテーションを使う場合
// OK:名前付きパラメーターを使う
@Query("SELECT u FROM User u WHERE u.name = :name")
List<User> findByName(@Param("name") String name);
// NG:文字列連結はしない
@Query(value = "SELECT * FROM users WHERE name = '" + name + "'", nativeQuery = true)
nativeQuery = true を使う場面は生SQLを書くことになるため、プレースホルダーを必ず使うようにしましょう。
注意が必要なパターン
① 動的なWHERE句の追加
// NG:条件によってSQL文字列を組み立てている
String sql = "SELECT * FROM users WHERE 1=1";
if (name != null) {
sql += " AND name = '" + name + "'"; // 危険
}
// OK:プレースホルダーで組み立てる
String sql = "SELECT * FROM users WHERE 1=1";
List<Object> params = new ArrayList<>();
if (name != null) {
sql += " AND name = ?";
params.add(name);
}
PreparedStatement ps = conn.prepareStatement(sql);
for (int i = 0; i < params.size(); i++) {
ps.setObject(i + 1, params.get(i));
}
② ORDER BY句はプレースホルダーが使えない
ORDER BY のカラム名はプレースホルダーで渡せません(値ではなく識別子のため)。
// プレースホルダーは使えない(カラム名なのでエスケープ対象外)
String sql = "SELECT * FROM users ORDER BY ?"; // 意図した動きにならない
この場合は、許可するカラム名をホワイトリストで管理するのが安全策です。
private static final Set<String> ALLOWED_SORT_COLUMNS =
Set.of("name", "email", "created_at");
public List<User> findAllSorted(String sortColumn) {
if (!ALLOWED_SORT_COLUMNS.contains(sortColumn)) {
throw new IllegalArgumentException("不正なソートカラム: " + sortColumn);
}
String sql = "SELECT * FROM users ORDER BY " + sortColumn;
// ...
}
まとめ
PreparedStatementが安全な理由は一言でいうと、「SQL文の構造」と「バインドする値」を完全に分離しているからです。
| 文字列連結 | PreparedStatement | |
|---|---|---|
| SQL構造の確定タイミング | 値を混ぜた後 | 値をバインドする前 |
| 値の扱い | SQL命令の一部になりうる | データとしてのみ扱われる |
| SQLインジェクション | 発生しうる | 防げる |
Spring BootやJPAを使っていれば、多くの場面はフレームワークが守ってくれます。ただし nativeQuery や動的SQL、ORDER BY のカラム名指定など、「自分でSQL文字列を組み立てる場面」では必ず意識するようにしましょう。
「ユーザーの入力値を文字列として直接SQLに連結していないか?」—— このチェックを習慣にしておくだけで、ほとんどのケースは防げます。