AIによる要約
利用者の入力値をSQL文字列へ直接連結すると、引用符を含む名前でSQLが壊れるだけでなく、入力がSQL構造として解釈されるSQLインジェクションの原因になります。値はPreparedStatementやJdbcTemplateの?、NamedParameterJdbcTemplateの名前付きパラメータへ渡し、SQLとデータを分離します。ただしテーブル名やORDER BY列などの識別子はプレースホルダー化できないため、許可リストから選びます。


SQL文字列連結は、コード量が少なく、テストデータでは動くため混入しやすい問題です。しかし、入力値がSQLの一部として解釈されると、検索条件を変えられたり、想定以上の行を取得・更新したりする可能性があります。
「画面のプルダウンだから安全」「ログイン後だけだから安全」ではありません。改修で入力経路が増えることもあり、内部利用者の誤入力でも構文エラーになります。値をSQLから分離することを実装ルールにします。
この記事のポイント
- 入力値をSQLへ文字列連結しない
- 値はプレースホルダーとバインド変数で渡す
- エスケープ処理の自作を対策にしない
- ORDER BY列などの識別子は許可リストから選ぶ
- 正常値だけでなく引用符や境界値もテストする
Xのコードレビュー画像で問題点を確認する

String sql = "SELECT * FROM users "
+ "WHERE name = '" + name + "'";
return jdbcTemplate.query(sql, rowMapper);`name`が`Alice`なら動きます。しかし値に`'`が含まれるとクォートの対応が崩れます。さらに、SQLの構文になる文字列を渡されると、WHERE条件そのものを変更される可能性があります。

XのBefore投稿とAfter投稿も参照できます。
文字列連結が危険な2つの理由
通常の入力でもSQLが壊れる
人名、商品名、備考にはアポストロフィや記号が入ることがあります。利用者に攻撃意図がなくても、SQL文字列とデータの境界が崩れ、構文エラーになります。
この問題を「特定文字を置換する」で直すと、DB製品、文字コード、エスケープ規則、入力経路の違いを自前で管理することになります。値はドライバへ渡し、適切な方法でバインドさせます。
入力がSQL構造として解釈される
値を連結すると、入力文字列が単なる名前ではなく、演算子や条件式としてSQLへ混ざる可能性があります。これがSQLインジェクションです。SELECTだけでなく、UPDATEやDELETEで起これば影響行数が拡大します。
社内システムでも入力値を信用しない
入力元は画面だけとは限りません。CSV、外部API、バッチ引数、移行データ、別システムからの連携値もあります。将来の改修で境界が変わっても安全な実装にします。
JdbcTemplateでは?プレースホルダーを使う
String sql = """
SELECT id, name, email
FROM users
WHERE name = ?
""";
return jdbcTemplate.query(
sql,
rowMapper,
name);SQL本体には`?`を置き、値はqueryの引数として渡します。JDBCドライバはSQL構造と値を分けて扱うため、name内の文字がWHERE句の構造として実行されません。
複数条件では、`?`の順序と引数の順序を揃えます。
String sql = """
SELECT id, name
FROM users
WHERE status = ?
AND created_at >= ?
""";
return jdbcTemplate.query(
sql,
rowMapper,
status,
from);PreparedStatementでも値をバインドする
String sql = """
SELECT id, name
FROM users
WHERE name = ?
""";
try (PreparedStatement statement =
connection.prepareStatement(sql)) {
statement.setString(1, name);
try (ResultSet resultSet =
statement.executeQuery()) {
// 結果を読み取る
}
}JDBCを直接使う場合も同じです。PreparedStatementへSQLと値を別々に渡します。文字列だけでなく数値、日付、booleanも対応するsetメソッドで設定します。
名前付きパラメータで条件を読みやすくする
String sql = """
SELECT id, name
FROM users
WHERE status = :status
AND created_at >= :from
""";
MapSqlParameterSource params =
new MapSqlParameterSource()
.addValue("status", status)
.addValue("from", from);
return namedParameterJdbcTemplate.query(
sql,
params,
rowMapper);条件が増えると`?`と引数順の対応を間違えやすくなります。`NamedParameterJdbcTemplate`なら、SQL上の名前とJava側の値を対応させられます。案件で採用済みの方式へ揃えてください。
LIKE検索でも値は連結しない
String sql = """
SELECT id, name
FROM users
WHERE name LIKE ?
""";
String pattern = "%" + keyword + "%";
return jdbcTemplate.query(
sql,
rowMapper,
pattern);`%`をJava側で値へ付けても、SQL構造自体は連結していません。ただし利用者が入力した`%`や`_`をワイルドカードとして扱うか、文字として検索するかは仕様です。文字扱いならDBに合ったエスケープとESCAPE句を共通処理で実装します。
ORDER BYの列名はプレースホルダーにできない
SELECT id, name
FROM users
ORDER BY ?プレースホルダーは値を渡す仕組みです。列名、テーブル名、ASC・DESCなどのSQL識別子や構文は値としてバインドできません。画面からソート項目を受ける場合は、入力値をそのまま連結せず、許可リストへ変換します。
String orderBy = switch (sortKey) {
case "name" -> "name";
case "createdAt" -> "created_at";
default -> "id";
};
String direction = ascending ? "ASC" : "DESC";
String sql = """
SELECT id, name
FROM users
ORDER BY %s %s
""".formatted(orderBy, direction);連結・埋め込みしている文字列は、サーバー側で定義した候補だけです。利用者入力そのものはSQLへ入りません。項目が多い場合はenumで管理すると、画面値とDB列名の対応をまとめられます。
IN句はフレームワークの展開機能を使う
String sql = """
SELECT id, name
FROM users
WHERE id IN (:ids)
""";
MapSqlParameterSource params =
new MapSqlParameterSource("ids", userIds);
return namedParameterJdbcTemplate.query(
sql,
params,
rowMapper);ID一覧をカンマ区切り文字列へして連結しないでください。空Listの扱いはライブラリやDBで異なるため、0件なら検索せず空Listを返すなど、事前条件を決めます。
ログへ完成SQLを出そうとして連結しない
調査用に「値を埋め込んだSQL」を手作りすると、そのコードが実行用へ流用される危険があります。ログはSQLテンプレートと必要なパラメータを分け、個人情報や秘密情報を出さないようにします。
log.debug(
"ユーザー検索を実行します status={}, from={}",
status,
from);パスワード、トークン、全文の個人情報はログへ出しません。SQL調査はDataSource ProxyやDB側の監査機能など、案件標準の方法も確認します。
引用符と境界値を含むテストを書く
@Test
void 引用符を含む名前も値として検索できる() {
userRepository.insert(
new User("U001", "O'Brien"));
List<User> actual =
userRepository.findByName("O'Brien");
assertEquals(1, actual.size());
assertEquals("U001", actual.get(0).getId());
}
@Test
void 空のID一覧ではSQLを実行せず空Listを返す() {
List<User> actual =
userRepository.findByIds(List.of());
assertTrue(actual.isEmpty());
}通常の英数字だけでなく、引用符、空文字、null、長い文字列、LIKEの`%`・`_`、空のIN条件を確認します。セキュリティ試験だけに任せず、Repositoryの単体・結合テストへ正常な特殊文字を含めます。
現場レビューでよくある指摘
// レビューコメント例
画面入力のnameをSQLへ直接連結しているため、
プレースホルダーで値をバインドしてください。
// レビューコメント例
独自のreplaceで引用符を逃がしていますが、
DBごとの差異と抜け漏れを避けるためPreparedStatementへ任せてください。
// レビューコメント例
sortKeyをORDER BYへ直接連結しています。
許可する列をenumまたはswitchでサーバー側から選んでください。
// レビューコメント例
IN句のID一覧を文字列化しています。
NamedParameterJdbcTemplateのList展開を使用してください。提出前のセルフチェック
- 入力値をSQL文字列へ連結していないか
- 値をプレースホルダーで渡したか
- 独自エスケープへ依存していないか
- ORDER BY列を許可リストから選んだか
- LIKEのワイルドカード仕様を決めたか
- IN句の空Listを扱ったか
- ログへ機密情報を出していないか
- 引用符と境界値をテストしたか
SQLアンチパターンを体系的に学ぶ参考書
SQLインジェクション対策に加え、読みづらい動的SQLやデータ設計の問題をレビューできるようにするには、アンチパターンを事例から学ぶ方法が役立ちます。
SQLアンチパターン 第2版
動くSQLの先にある、設計上の失敗パターンを知る。
テーブル設計やクエリで繰り返される問題を、原因と改善策から学べます。基礎SQLを終えた後の一冊です。
- SQLレビューの観点を増やしたい
- DB設計の失敗例から学びたい
当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。
この記事とあわせて読みたい
まとめ
入力値をSQLへ文字列連結すると、通常の引用符でも構文が壊れ、入力がSQL構造として解釈されるSQLインジェクションの原因になります。
値はPreparedStatement、JdbcTemplate、NamedParameterJdbcTemplateのプレースホルダーへ渡します。列名などプレースホルダーにできない部分は許可リストから選び、特殊文字と境界値を含むテストで安全な契約を確認しましょう。
