Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 46

Prévention des Injections SQL avec PreparedStatement

Recommended resources

Bonjour ! Dans la leçon précédente, nous avons appris à établir et à gérer proprement une connexion à notre base de données MySQL en utilisant DriverManager et try-with-resources. Nous avons maintenant un canal de communication fiable.

Aujourd'hui, nous allons utiliser cette connexion pour sa raison d'être : exécuter des requêtes SQL. Cependant, il ne suffit pas de les exécuter ; il faut le faire de manière sécurisée. L'objectif de cette leçon est d'apprendre à utiliser l'objet PreparedStatement pour exécuter des requêtes de manière sûre et efficace, en nous protégeant contre l'une des failles de sécurité les plus courantes et les plus dangereuses : l'injection SQL.

1. Le danger : l'injection SQL

Imaginez que vous deviez vérifier les identifiants d'un utilisateur. L'approche la plus naïve serait de construire la requête SQL en concaténant directement les chaînes de caractères fournies par l'utilisateur (son login et son mot de passe).

// ATTENTION : CODE VULNÉRABLE !
String login = //... entrée utilisateur
String password = //... entrée utilisateur

String sql = "SELECT * FROM users WHERE login = '" + login + "' AND password = '" + password + "'";

Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(sql); // Exécution de la requête concaténée

À première vue, cela semble fonctionner. Mais que se passe-t-il si un utilisateur malveillant saisit quelque chose de spécial dans le champ du login ? Par exemple : admin' --.

La requête SQL deviendrait :
SELECT * FROM users WHERE login = 'admin' -- ' AND password = '...'

En SQL, -- est un commentaire. Tout ce qui suit est ignoré. La base de données exécute donc SELECT * FROM users WHERE login = 'admin', ignorant complètement la vérification du mot de passe. L'attaquant vient de se connecter en tant qu'administrateur sans connaître le mot de passe. C'est une injection SQL.

Pour voir concrètement l'impact dévastateur de cette faille, regardez la vidéo suivante.

TUTO Java JDBC - Eviter les attaques par Injection SQL

Cette vidéo de Dominique Liard montre de manière très claire comment une application simple peut être compromise par une injection SQL.

Concentrez-vous sur les deux démonstrations d'attaques : Bypass d'authentification : Voyez comment un attaquant peut se connecter en tant qu'un autre utilisateur sans connaître son mot de passe. Usurpation d'identité admin : Observez une attaque encore plus grave qui permet de se connecter en tant que premier utilisateur de la base de données (souvent l'administrateur). L'auteur montre même qu'il serait possible d'exécuter des commandes destructrices, comme DROP TABLE.

Cette vulnérabilité provient du fait que nous avons mélangé le code (la structure de la requête SQL) et les données (l'entrée de l'utilisateur) dans une seule chaîne de caractères. La solution est de les séparer.


2. La solution : PreparedStatement

L'API JDBC fournit une solution robuste à ce problème : l'interface PreparedStatement. Le principe est simple mais puissant :

  1. Vous écrivez votre requête SQL comme un modèle (template), en utilisant des points d'interrogation (?) comme espaces réservés (placeholders) pour les valeurs dynamiques.
  2. Vous fournissez ensuite les valeurs pour ces paramètres séparément, via des méthodes spécifiques comme setString(), setInt(), etc.
  3. Le pilote JDBC s'assure que ces valeurs sont traitées uniquement comme des données. Même si une valeur contient des caractères SQL spéciaux (comme ' ou --), ils seront "échappés" et ne pourront jamais modifier la structure de la requête SQL.

Le diagramme ci-dessous illustre ce flux sécurisé.

Ce diagramme montre comment la requête SQL (avec son placeholder `?`) et les paramètres ('pepe', 'ana') sont envoyés à la base de données. Le pilote JDBC s'assure que les paramètres remplissent les placeholders sans altérer la requête elle-même.

La comparaison de code suivante rend la différence très claire.

À gauche, la construction d'une requête par concaténation, une pratique dangereuse. À droite, l'utilisation sécurisée de `PreparedStatement` avec un placeholder et la méthode `setString()` pour injecter la valeur sans risque.

Pour une explication textuelle détaillée, consultez la ressource suivante.

Accès aux bases de données : JDBC — Java EE

Cette documentation explique de manière concise et technique le fonctionnement des requêtes préparées.

Lisez les sections suivantes : "Le PreparedStatement" : Pour comprendre comment créer un PreparedStatement et comment définir ses paramètres avec les méthodes setXXX(). Notez le détail important : l'index des paramètres commence à 1, pas à 0. "L’injection SQL" : Cette partie contient un exemple de code avant/après qui résume parfaitement le problème et sa solution avec PreparedStatement.


3. Mettre en œuvre le PreparedStatement

Voyons maintenant comment transformer le code vulnérable en code sécurisé.

Le processus se déroule en trois étapes :

  1. Préparer la requête : On utilise connection.prepareStatement(sql) avec une requête contenant des ?.
  2. Lier les paramètres : On appelle pstmt.setXXX(index, valeur) pour chaque ?. XXX correspond au type de données (String, Int, Date, etc.).
  3. Exécuter la requête : On appelle pstmt.executeQuery() (pour un SELECT) ou pstmt.executeUpdate() (pour INSERT, UPDATE, DELETE). Notez que ces méthodes sont appelées sans argument, car la requête a déjà été fournie lors de la préparation.

La vidéo ci-dessous vous montre exactement comment refactoriser le code vulnérable vu précédemment pour utiliser PreparedStatement.

TUTO Java JDBC - Eviter les attaques par Injection SQL

Nous reprenons la même vidéo pour voir l'implémentation de la solution et la vérification de son efficacité.

Regardez attentivement ces deux parties : Correction avec PreparedStatement : L'auteur modifie son code pour utiliser connection.prepareStatement(), les placeholders ?, et les méthodes setString() et setInt(). C'est l'implémentation concrète de ce que nous venons d'apprendre. Test de la solution : Les tentatives d'injection SQL qui fonctionnaient auparavant échouent maintenant, prouvant que la solution est efficace.


4. Exercice Pratique : Sécuriser une méthode d'authentification

Il est temps de mettre cela en pratique. Nous allons reprendre le projet de la leçon précédente et y ajouter une méthode de vérification d'utilisateur sécurisée.

  1. Ouvrez votre projet Maven. Assurez-vous que votre base de données contient une table users avec des colonnes login et password. Si ce n'est pas le cas, créez-la et insérez quelques utilisateurs.

    CREATE TABLE users (
        id INT AUTO_INCREMENT PRIMARY KEY,
        login VARCHAR(50) NOT NULL UNIQUE,
        password VARCHAR(50) NOT NULL
    );
    
    INSERT INTO users (login, password) VALUES ('jdoe', 'pass123'), ('admin', 'adminpass');
    
  2. Dans votre classe Main, ajoutez la méthode checkUser suivante. Pour l'instant, elle est vide.

    public static boolean checkUser(Connection connection, String login, String password) throws SQLException {
        // TODO: Implémentez la logique de vérification sécurisée ici
        return false;
    }
    
  3. Votre tâche : Complétez la méthode checkUser en utilisant un PreparedStatement pour vérifier si un utilisateur avec le login et le password fournis existe dans la base de données. La méthode doit retourner true si l'utilisateur existe, false sinon.

  4. Modifiez votre méthode main pour appeler checkUser à l'intérieur du bloc try-with-resources de la connexion, et testez différents scénarios.

    // Dans votre méthode main, à l'intérieur du try-with-resources de la connexion
    try (Connection connection = DriverManager.getConnection(url, user, password)) {
        System.out.println("Connexion établie avec succès !");
    
        // Scénario 1: Identifiants corrects
        boolean isUser1Valid = checkUser(connection, "jdoe", "pass123");
        System.out.println("Tentative de connexion pour jdoe: " + (isUser1Valid ? "Succès" : "Échec"));
    
        // Scénario 2: Mot de passe incorrect
        boolean isUser2Valid = checkUser(connection, "jdoe", "wrongpassword");
        System.out.println("Tentative de connexion pour jdoe avec mauvais mdp: " + (isUser2Valid ? "Succès" : "Échec"));
    
        // Scénario 3: Tentative d'injection SQL
        boolean isInjectionValid = checkUser(connection, "admin' --", "garbage");
        System.out.println("Tentative d'injection SQL: " + (isInjectionValid ? "Succès (FAILLE !)" : "Échec (SÉCURISÉ !)"));
    
    } catch (SQLException e) {
        // ...
    }
    
Cliquez ici pour voir la solution
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

// ... autres imports

public class Main {

    public static boolean checkUser(Connection connection, String login, String password) throws SQLException {
        // 1. Définir la requête avec des placeholders
        String sql = "SELECT * FROM users WHERE login = ? AND password = ?";
        
        // 2. Créer un PreparedStatement dans un try-with-resources pour une fermeture automatique
        try (PreparedStatement pstmt = connection.prepareStatement(sql)) {
            
            // 3. Lier les paramètres
            pstmt.setString(1, login);      // Le premier '?' est le login
            pstmt.setString(2, password);   // Le deuxième '?' est le mot de passe
            
            System.out.println("Exécution de la requête : " + pstmt);

            // 4. Exécuter la requête et obtenir le ResultSet
            try (ResultSet rs = pstmt.executeQuery()) {
                
                // 5. Vérifier si le ResultSet contient au moins une ligne
                // rs.next() déplace le curseur sur la première ligne et retourne 'true' si elle existe.
                return rs.next();
            }
        }
    }

    public static void main(String[] args) {
        // ... votre code de chargement des propriétés et de connexion ...
        
        try (Connection connection = DriverManager.getConnection(url, user, password)) {
            System.out.println("Connexion établie avec succès !");

            // Scénario 1: Identifiants corrects
            boolean isUser1Valid = checkUser(connection, "jdoe", "pass123");
            System.out.println("Tentative de connexion pour jdoe: " + (isUser1Valid ? "Succès" : "Échec"));

            // Scénario 2: Mot de passe incorrect
            boolean isUser2Valid = checkUser(connection, "jdoe", "wrongpassword");
            System.out.println("Tentative de connexion pour jdoe avec mauvais mdp: " + (isUser2Valid ? "Succès" : "Échec"));

            // Scénario 3: Tentative d'injection SQL
            boolean isInjectionValid = checkUser(connection, "admin' -- ", "garbage");
            System.out.println("Tentative d'injection SQL: " + (isInjectionValid ? "Succès (FAILLE !)" : "Échec (SÉCURISÉ !)"));

        } catch (SQLException e) {
            System.err.println("Échec de la connexion : " + e.getMessage());
            e.printStackTrace();
        }
    }
}

Conclusion

Félicitations ! Vous venez de maîtriser une compétence absolument fondamentale et non négociable pour tout développeur backend : la protection contre les injections SQL.

Points clés à retenir :

  • La concaténation de chaînes de caractères pour construire des requêtes SQL à partir d'entrées utilisateur est extrêmement dangereuse et doit être bannie de votre code.
  • Le PreparedStatement est la solution standard et robuste pour exécuter des requêtes dynamiques en toute sécurité.
  • Le processus implique la création d'une requête "modèle" avec des ?, la liaison des paramètres avec les méthodes setXXX(), et enfin l'exécution de la requête préparée.

Maintenant que nous savons envoyer des requêtes de manière sécurisée, il nous reste une étape cruciale : comment lire et traiter efficacement les données que la base de données nous renvoie ? Ce sera l'objet de notre prochaine leçon, où nous explorerons en profondeur le ResultSet.