SQL Injection et Prepared Statement en Java

1. Aperçu

SQL injection est une vulnérabilité de sécurité majeure dans la liste OWASP, issue de la concaténation manuelle de SQL avec des données d’entrée utilisateur. Cet article explique le mécanisme d’attaque, pourquoi PreparedStatement le bloque, et comment l’appliquer à JDBC, JPA/Hibernate, ainsi qu’à MyBatis. Après la lecture, vous écrirez du code de requête sûr et saurez comment écrire des tests pour détecter rapidement les vulnérabilités.

2. Comment SQL injection fonctionne

L’erreur se produit quand vous concaténez directement la valeur d’entrée dans une chaîne SQL, au lieu de la paramétrer.

// NE PAS FAIRE COMME CECI
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;
    }
}

Si username est une valeur valide comme "minh", le SQL généré est :

SELECT * FROM customers WHERE username = 'minh'

Mais si un attaquant passe username = "' OR '1'='1", le SQL devient :

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

La condition '1'='1' est toujours vraie, donc la requête retourne la table complète customers — ignorant complètement la logique d’authentification du username. Pire, l’attaquant peut ajouter ; DROP TABLE customers; -- pour supprimer les données, ou utiliser UNION SELECT pour lire une autre table (par exemple la table users contenant des hashes de mots de passe).

Remarque : L’entrée ne vient pas seulement des formulaires HTML. Les en-têtes HTTP, les cookies, les paramètres de requête, ou les données importées depuis des fichiers sont toutes des sources d’attaque potentielles.

3. Comment Prepared Statement résout le problème

3.1. Mécanisme de paramétrage

PreparedStatement sépare la structure SQL (envoyée d’abord, parsée et compilée par la DB en plan d’exécution) des valeurs de paramètres (envoyées après, traitées uniquement comme données pures). Ainsi, les entrées d’un attaquant ne sont jamais interprétées par la DB comme de la syntaxe 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;
        }
    }
}
  • Le ? est un placeholder, la DB compile la déclaration avec ce placeholder.
  • stmt.setString(1, username) assigne la valeur à la position du paramètre 1 (numérotée à partir de 1, pas 0).
  • Même si username contient ' OR '1'='1, la DB comprend que c’est une seule chaîne de caractères à comparer, pas de la syntaxe SQL. La requête retourne vide car aucun utilisateur n’a ce nom étrange.

3.2. Named parameter avec Spring JdbcTemplate

Si vous utilisez Spring, NamedParameterJdbcTemplate rend le code plus lisible que les ? numérotés.

<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 est un named placeholder, Spring le convertit en ? en interne et lie la valeur de manière sûre. Cette approche évite les erreurs d’ordre de paramètres quand le SQL a de nombreuses conditions.

4. Application avec JPA/Hibernate

JPA génère par défaut du SQL utilisant prepared statements, mais vous pouvez toujours vous tirer une balle dans le pied si vous utilisez mal l’API.

// SÛRE — JPQL avec named parameter
public List<Order> findOrdersByStatus(String status) {
    return entityManager.createQuery(
            "SELECT o FROM Order o WHERE o.status = :status", Order.class)
        .setParameter("status", status)
        .getResultList();
}
// DANGEREUSE — concaténation de chaîne dans 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) dans l’exemple sûr fait en sorte que Hibernate traduise JPQL en SQL avec ?, puis lie la valeur via PreparedStatement — un attaquant ne peut pas modifier la structure de la commande.
  • Dans l’exemple dangereux, Hibernate parse toujours le JPQL, mais la chaîne JPQL est « souillée » avant le parsing, donc un attaquant pourrait injecter du JPQL injection (similaire à SQL injection, mais à un niveau de syntaxe différent).

Avec Spring Data JPA, @Query doit aussi utiliser des paramètres au lieu de concaténer :

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. Cas où on ne peut pas paramétrer : noms de tables, noms de colonnes

PreparedStatement peut paramétrer uniquement les valeurs, pas les identifieurs (noms de tables, noms de colonnes, clause ORDER BY). C’est une situation que beaucoup ignorent.

// Tri dynamique par colonne selon le choix de l'utilisateur
public List<Order> findAllSorted(String sortColumn) {
    Set<String> allowedColumns = Set.of("id", "createdAt", "totalAmount");
    if (!allowedColumns.contains(sortColumn)) {
        throw new IllegalArgumentException("Colonne de tri invalide : " + sortColumn);
    }
    String sql = "SELECT * FROM orders ORDER BY " + sortColumn; // sûr car whitelisté
    // ... exécuter comme d'habitude avec PreparedStatement
    return jdbc.query(sql, this::mapRow);
}
  • Puisqu’on ne peut pas écrire ORDER BY ?, vous devez whitelister les valeurs valides avant de les concaténer.
  • N’utilisez jamais une blacklist (bloquer des mots-clés comme DROP, ;) car un attaquant trouve toujours un moyen de contourner l’encodage ou les variantes de syntaxe.

6. Écrire des tests pour détecter tôt les vulnérabilités

Un test simple vous aide à confirmer que votre endpoint se défend correctement contre l’injection.

@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();
    }
}
  • Le premier test envoie une charge injection classique, s’attendant à un résultat vide (car aucun username ne correspond à cette chaîne littérale) plutôt que toutes les données.
  • Le deuxième test vérifie que l’endpoint rejette une entrée qui n’est pas dans la whitelist des colonnes de tri — retourne 400 Bad Request au lieu d’exécuter du SQL dangereux.
  • assertThat(result).isEmpty() utilise AssertJ pour vérifier que l’Optional est vide, prouvant que la requête ne peut pas être contournée.

7. Erreurs courantes

  • Utiliser Statement.executeQuery(String) pour des entrées dynamiques : rapide pour les démos mais c’est une porte dérobée en production.
  • Utiliser String.format pour « simplifier » le SQL : c’est toujours de la concaténation, pas différent du +.
  • Faire confiance à 100% à l’ORM : JPQL/HQL/Criteria peuvent quand même être injectés si vous concatenez des chaînes avant d’appeler createQuery.
  • Échapper manuellement au lieu de paramétrer : écrire votre propre fonction d’échappement des guillemets simples (replace("'", "\\'")) risque de manquer des cas (par exemple Unicode, multi-byte) et n’est pas aussi fiable que le mécanisme natif du driver.
  • Clause IN dynamique mal implémentée : quand vous construisez WHERE id IN (?, ?, ?) avec un nombre de paramètres variable, vous devez générer le bon nombre de ? correspondant à la taille de la liste, pas concaténer directement les valeurs.

Remarque : Les prepared statements protègent contre SQL injection mais pas contre d’autres injections comme command injection, LDAP injection ou NoSQL injection. Chaque couche de données a besoin de son propre mécanisme de paramétrage.

8. Quand on n’a pas besoin de PreparedStatement

Si le SQL est entièrement statique, ne contenant aucune valeur dynamique provenant d’entrée utilisateur (par exemple SELECT COUNT(*) FROM customers), utiliser un Statement classique est toujours sûr et parfois un peu plus léger car on évite le parsing/binding. Cependant en pratique, la plupart des requêtes ont des conditions dynamiques, donc par défaut préférez PreparedStatement ou son équivalent (JPQL avec setParameter, MyBatis avec #{} au lieu de ${}) comme habitude, pour éviter d’avoir à mémoriser « dans quel cas c’est sûr ».

9. Conclusion

SQL injection vient du mélange du code et des données dans une même chaîne SQL. PreparedStatement le résout à la source en séparant ces deux éléments : la structure de la déclaration est compilée en avance, les valeurs de paramètres ne sont liées après que comme données pures. Appliquez cela systématiquement en JDBC, JPQL, MyBatis, whitelistez toujours les identifieurs dynamiques comme les noms de colonnes/tables, et écrivez des tests avec les charges injection classiques pour vous assurer que vos endpoints n’exécutent jamais du SQL contrôlé par un attaquant.

web-securitysql-injectionjdbcspring-bootjava