SQL インジェクションと Java の Prepared Statement

1. 概要

SQL インジェクションは OWASP トップリストのセキュリティ脆弱性で、手動で SQL 文を組み立ててユーザー入力を含めることから生じます。本記事では攻撃の仕組みを解説し、PreparedStatement がなぜそれをブロックできるのか示し、JDBC、JPA/Hibernate、MyBatis での実装方法をガイドします。読了後、安全なクエリコードを書けるようになり、脆弱性を早期に発見するためのテストの書き方を習得できます。

2. SQL インジェクションがどのように機能するか

脆弱性は、ユーザー入力値を SQL 文字列に直接連結する場合に発生し、パラメータ化しません。

// このようにしてはいけません
public Customer findByUsername(String username) throws SQLException {
    String sql = "SELECT * FROM customers WHERE username = '" + username + "'";
    try (Statement stmt = connection.createStatement();
         ResultSet rs = stmt.executeQuery(sql)) {
        return rs.next() ? mapRow(rs) : null;
    }
}

username が "minh" のような正当な値なら、生成される SQL は:

SELECT * FROM customers WHERE username = 'minh'

しかし攻撃者が username = "' OR '1'='1" を渡した場合、SQL は以下のようになります:

SELECT * FROM customers WHERE username = '' OR '1'='1'

'1'='1' は常に真なので、クエリは customers テーブル全体を返します。ユーザー名認証ロジックが完全にバイパスされます。さらに悪い場合は、攻撃者が ; DROP TABLE customers; -- を追加してデータを削除したり、UNION SELECT で別のテーブル(パスワードハッシュを含む users テーブルなど)を読み取ることもできます。

注意: 入力はHTMLフォームからだけではありません。HTTP ヘッダー、Cookie、クエリパラメータ、ファイルアップロードのデータなど、あらゆるソースが潜在的な攻撃媒体になります。

3. Prepared Statement がどのように解決するか

3.1. パラメータ化の仕組み

PreparedStatement は SQL 構造(事前に送信され、データベースでコンパイルされて実行計画になります)とパラメータ値(後で送信され、純粋なデータとしてのみ扱われます)を分離します。そのため、攻撃者の入力がデータベースによって SQL 構文として解釈されることはありません。

public Customer findByUsername(String username) throws SQLException {
    String sql = "SELECT * FROM customers WHERE username = ?";
    try (PreparedStatement stmt = connection.prepareStatement(sql)) {
        stmt.setString(1, username);
        try (ResultSet rs = stmt.executeQuery()) {
            return rs.next() ? mapRow(rs) : null;
        }
    }
}
  • ? はプレースホルダーで、データベースはこのプレースホルダーを持つ文を事前にコンパイルします。
  • stmt.setString(1, username) は最初のパラメータ位置に値を割り当てます(0 からではなく 1 から始まります)。
  • username に ' OR '1'='1 が含まれていても、データベースはこれを照合すべき単一の文字列リテラルとして理解し、SQL 構文ではありません。その奇妙な名前のユーザーが存在しないため、クエリは空を返します。

3.2. Spring JdbcTemplate での名前付きパラメータ

Spring を使用している場合、NamedParameterJdbcTemplate は ? の序数より読みやすいコードを実現します。

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-jdbc</artifactId>
</dependency>
@Repository
public class CustomerRepository {

    private final NamedParameterJdbcTemplate jdbc;

    public CustomerRepository(NamedParameterJdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public Optional<Customer> findByUsername(String username) {
        String sql = "SELECT id, username, email FROM customers WHERE username = :username";
        Map<String, Object> params = Map.of("username", username);
        List<Customer> result = jdbc.query(sql, params, this::mapRow);
        return result.stream().findFirst();
    }

    private Customer mapRow(ResultSet rs, int rowNum) throws SQLException {
        return new Customer(rs.getLong("id"), rs.getString("username"), rs.getString("email"));
    }
}

:username は名前付きプレースホルダーで、Spring が内部的に ? に変換し、安全に値をバインドします。SQL に複数の条件がある場合、このアプローチはパラメータの順序混乱を防ぐのに役立ちます。

4. JPA/Hibernate での実装

JPA はデフォルトで prepared statement を使用して SQL を生成しますが、API を誤って使用すると、脆弱性を作り込む可能性があります。

// 安全 — JPQL と名前付きパラメータ
public List<Order> findOrdersByStatus(String status) {
    return entityManager.createQuery(
            "SELECT o FROM Order o WHERE o.status = :status", Order.class)
        .setParameter("status", status)
        .getResultList();
}
// 危険 — JPQL への文字列連結
public List<Order> findOrdersByStatusUnsafe(String status) {
    String jpql = "SELECT o FROM Order o WHERE o.status = '" + status + "'";
    return entityManager.createQuery(jpql, Order.class).getResultList();
}
  • 安全な例では setParameter("status", status) で、Hibernate は JPQL を ? を含む SQL に変換し、PreparedStatement 経由で値をバインドします。攻撃者は文の構造に介入できません。
  • 危険な例では、Hibernate は JPQL をパースしますが、パース前に JPQL 文字列が汚染されているため、攻撃者は JPQL インジェクション(SQL インジェクションと同じですが、構文層が異なります)を実行できます。

Spring Data JPA でも @Query はパラメータを使用する必要があり、文字列連結は避けるべきです:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o WHERE o.customerEmail = :email AND o.status = :status")
    List<Order> findByEmailAndStatus(@Param("email") String email, @Param("status") String status);
}

5. パラメータ化できないケース:テーブル名と列名

PreparedStatement はパラメータ化できるのは値のみで、識別子(テーブル名、列名、ORDER BY 句)はパラメータ化できません。これは多くの開発者が見落とす状況です。

// ユーザーが選択した列で動的にソート
public List<Order> findAllSorted(String sortColumn) {
    Set<String> allowedColumns = Set.of("id", "createdAt", "totalAmount");
    if (!allowedColumns.contains(sortColumn)) {
        throw new IllegalArgumentException("ソート列が無効です:" + sortColumn);
    }
    String sql = "SELECT * FROM orders ORDER BY " + sortColumn; // ホワイトリストしたため安全
    // 通常の PreparedStatement で実行
    return jdbc.query(sql, this::mapRow);
}
  • ORDER BY ? と書くことができないため、文字列連結の前に有効な値をホワイトリストする必要があります。
  • ブラックリスト(DROP などのキーワードや ; をブロック)を使用しないでください。攻撃者はエンコーディングや構文の変形でそれを回避できます。

6. テストを書いて脆弱性を早期に発見する

シンプルなテストでエンドポイントがインジェクション対策を正しく実装しているか確認できます。

@SpringBootTest
@AutoConfigureMockMvc
class CustomerSearchSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void searchByUsername_withInjectionPayload_returnsEmptyNotAllRows() throws Exception {
        String payload = "' OR '1'='1";

        mockMvc.perform(get("/api/customers").param("username", payload))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$").isEmpty());
    }

    @Test
    void sortBy_withInvalidColumn_rejectsRequest() throws Exception {
        mockMvc.perform(get("/api/orders").param("sortBy", "id; DROP TABLE orders"))
               .andExpect(status().isBadRequest());
    }
}
class CustomerRepositoryTest {

    @Test
    void findByUsername_withSqlInjectionPayload_returnsEmptyOptional() {
        CustomerRepository repo = new CustomerRepository(jdbcTemplate);

        Optional<Customer> result = repo.findByUsername("nonexistent' OR '1'='1");

        assertThat(result).isEmpty();
    }
}
  • 最初のテストは古典的なインジェクションペイロードを送信し、結果が空(その奇妙な名前のユーザーが存在しないため)であり、全データではないことを期待します。
  • 2 番目のテストは、ソート列のホワイトリストにない入力をエンドポイントがリジェクトすることを確認します。危険な SQL を実行する代わりに 400 Bad Request を返すことが期待されます。
  • assertThat(result).isEmpty() は AssertJ を使用して Optional が空であることを確認し、クエリがバイパスされていないことを証明します。

7. よくあるミス

  • 動的入力に Statement.executeQuery(String) を使用: デモを書くときは素早いですが、本番環境では実際の脆弱性です。
  • String.format で SQL を「簡潔に」する: これも文字列連結であり、+ での連結と変わりません。
  • ORM を 100% 信頼: JPQL/HQL/Criteria も、createQuery を呼び出す前に文字列を自分で連結すればインジェクション対象になります。
  • 手動エスケープをパラメータ化の代わりに使用: 独自のエスケープ関数を書く(replace("'", "\\'") など)と、Unicode エンコーディングやマルチバイト文字などのケースを見落としやすく、ドライバーのネイティブメカニズムほど信頼できません。
  • 動的 IN 句の誤った実装: WHERE id IN (?, ?, ?) を構築する場合、リストサイズに対応した正しい数の ? を生成する必要があります。値を直接連結してはいけません。

注意: Prepared statement は SQL インジェクションから保護しますが、別のレイヤー(コマンドインジェクション、LDAP インジェクション、NoSQL インジェクション)での攻撃は防ぎません。各データレイヤーには独自のパラメータ化メカニズムが必要です。

8. PreparedStatement が不要なケース

SQL 文が完全に静的で、ユーザー入力からの動的値を含まない場合(例えば SELECT COUNT(*) FROM customers)、通常の Statement を使用してもセキュアです。パース/バインドステップをスキップするため、わずかに軽いかもしれません。ただし実際のところ、ほとんどのクエリは動的条件を含むため、「このケースは安全か」と判断するのではなく、デフォルトとして PreparedStatement または同等のもの(setParameter 付き JPQL、#{} を用いた MyBatis など)を習慣的に使用する方が、常に安全です。

9. まとめ

SQL インジェクションは SQL 文字列の中でコードとデータを混在させることから生じます。PreparedStatement はこの 2 つを分離することで根本的に解決します。文の構造は事前にコンパイルされ、パラメータ値は純粋なデータとしてのみ後でバインドされます。JDBC、JPQL、MyBatis で一貫して適用し、列名やテーブル名などの動的識別子に対してはホワイトリストを設定し、古典的なインジェクションペイロードでテストを書いて、エンドポイントが攻撃者が制御する SQL を決して実行しないようにしましょう。

web-securitysql-injectionjdbcspring-bootjava