Javaで入力値をSQLへ文字列連結する危険とプレースホルダー対策を解説する記事のアイキャッチ。

開発現場メモ

JavaでSQLを文字列連結してはいけない理由|SQLインジェクションと対策

AIによる要約

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

新人SE
新人SE
検索画面の名前をSQLへ連結しただけです。社内システムでも直す必要がありますか?
ポンコツSE
ポンコツSE
必要です。悪意がなくても、アポストロフィを含む値でSQLが壊れます。入力経路に関係なく値はプレースホルダーへ分離します。

SQL文字列連結は、コード量が少なく、テストデータでは動くため混入しやすい問題です。しかし、入力値がSQLの一部として解釈されると、検索条件を変えられたり、想定以上の行を取得・更新したりする可能性があります。

「画面のプルダウンだから安全」「ログイン後だけだから安全」ではありません。改修で入力経路が増えることもあり、内部利用者の誤入力でも構文エラーになります。値をSQLから分離することを実装ルールにします。

この記事のポイント

  • 入力値をSQLへ文字列連結しない
  • 値はプレースホルダーとバインド変数で渡す
  • エスケープ処理の自作を対策にしない
  • ORDER BY列などの識別子は許可リストから選ぶ
  • 正常値だけでなく引用符や境界値もテストする

Xのコードレビュー画像で問題点を確認する

利用者のnameをSELECT文へ文字列連結したJavaコードを示すAI生成コードのレビュー画像。
入力値nameがSQL文字列へ直接埋め込まれています。
String sql = "SELECT * FROM users "
        + "WHERE name = '" + name + "'";

return jdbcTemplate.query(sql, rowMapper);

`name`が`Alice`なら動きます。しかし値に`'`が含まれるとクォートの対応が崩れます。さらに、SQLの構文になる文字列を渡されると、WHERE条件そのものを変更される可能性があります。

JdbcTemplateのSQLへ疑問符プレースホルダーを使い、nameをバインド値として渡す修正版画像。
SQLには?を置き、nameは別の値としてJdbcTemplateへ渡します。

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版」の表紙
商品画像:Amazon.co.jp
PR SQL基礎を終えた若手向け

SQLアンチパターン 第2版

Bill Karwin (著)

動くSQLの先にある、設計上の失敗パターンを知る。

テーブル設計やクエリで繰り返される問題を、原因と改善策から学べます。基礎SQLを終えた後の一冊です。

  • SQLレビューの観点を増やしたい
  • DB設計の失敗例から学びたい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

この記事とあわせて読みたい

まとめ

入力値をSQLへ文字列連結すると、通常の引用符でも構文が壊れ、入力がSQL構造として解釈されるSQLインジェクションの原因になります。

値はPreparedStatement、JdbcTemplate、NamedParameterJdbcTemplateのプレースホルダーへ渡します。列名などプレースホルダーにできない部分は許可リストから選び、特殊文字と境界値を含むテストで安全な契約を確認しましょう。

-開発現場メモ
-, , , , ,