Comment prévenir les injections SQL en PHP ?

Vous voulez faire une application web et vous vous inquiétez concernant la sécurité ? Nous vous montrons comment vous prémunir contre les injections SQL.

Comment prévenir les injections SQL en PHP ?

La faille SQLi, abréviation de SQL Injection, soit injection SQL en français, est un groupe de méthodes d'exploitation de faille de sécurité d'une application interagissant avec une base de données.

Imaginons que vous ayez un site web sur lequel vous demandez aux utilisateurs d'écrire leurs noms pour l'inscription et qu'au lieu d'écrire correctement son nom, un utilisateur mal intentionné écrive quelque chose du genre :

Robert'); DROP TABLE STUDENTS; --

Supposons toujours que de votre côté, vous avez écrit une requête SQL pour enregistrer les informations de l'utilisateur dans votre base de données et que votre requête ressemble à peu près à ceci :

q = « INSERT INTO Students VALUES (“$prenom”, “$nom”) » ;

C'est la façon la plus naïve d'ajouter du texte dans une requête, et c'est très mauvais, comme vous le verrez.

Une fois que les valeurs de la zone de texte "prenom" qui est Robert') ; DROP TABLE STUDENTS ; --) et de la zone de texte "nom" (appelons-le Mugabe) sont concaténées avec le reste de la requête, le résultat est en fait deux requêtes séparées par le point-virgule de fin d'instruction. La deuxième requête a été injectée dans la première. Lorsque le code exécute cette requête dans la base de données, elle se présente comme suit :

INSERT INTO Students VALUES ('Robert'); DROP TABLE Students; --', 'Mugabe')

ce qui, en français, se traduit grosso modo par les deux requêtes :

Requête #1 : Ajouter un nouvel enregistrement à la table Étudiants avec la valeur de Nom 'Robert'

INSERT INTO Students VALUES ('Robert');

Requête #2 : Supprimer la table Étudiants

DROP TABLE Students;

Tout ce qui dépasse la deuxième requête est marqué comme un commentaire : --', 'Mugabe')

Le ' dans le nom de l'étudiant n'est pas un commentaire, c'est le délimiteur de la chaîne de fermeture. Comme le nom de l'étudiant est une chaîne de caractères, il est nécessaire d'un point de vue syntaxique pour compléter la requête hypothétique.

NB : Les attaques par injection ne fonctionnent que si la requête SQL qu'elles injectent aboutit à une requête SQL valide. Si la requête n'est pas valide, l'injection ne va pas non plus marcher.

Peut être vous pouvez croire que ça peut être différent si l'on utilise des variables, mais voyons ce que ça donne : disons que le nom a été stocké dans une variable, $nom. Vous exécutez alors cette requête :

INSERT INTO Students VALUES ('$nom')

Avec ceci, vous comprendrez que le code place par erreur tout ce que l'utilisateur a saisi dans la variable $nom et que s'il s'agit d'une instruction SQL dans le nom, cette instruction va aussi s'exécuter. 

Je sais qu'au départ vous vouliez que le code SQL soit le suivant :

INSERT INTO Students VALUES ('Robert`)

Mais un utilisateur malvaillant peut fournir ce qu'il veut :

 Robert'); DROP TABLE Students ; --')

Avec ce bidouillage, la requête que vous obtiendrez n'est plus ce à quoi vous vous attendiez mais ressemble à ceci :

INSERT INTO Students VALUES ('Robert'); DROP TABLE Students; --' )

Le -- ne fait que commenter le reste de la ligne ; ce qui veut dire que tout ce qui sera écrit après -- sera considéré comme un commentaire et donc ne sera pas pris en compte lors de l'exécution de la requête.

Comment protéger votre site web contre les injections SQL en PHP ?

La bonne façon d'éviter les attaques par injection SQL, quelle que soit la base de données utilisée, est de séparer les données du langage SQL, de sorte que les données restent des données et ne soient jamais interprétées comme des commandes par l'analyseur SQL.

Il est possible de créer une instruction SQL avec des parties de données correctement formatées, mais si vous ne comprenez pas bien les détails, vous devriez toujours utiliser des instructions préparées et des requêtes paramétrées. Il s'agit d'instructions SQL qui sont envoyées au serveur de la base de données et analysées par lui sans aucun paramètre. De cette manière, il est impossible pour un attaquant d'injecter du code SQL malveillant.

Vous avez essentiellement deux options pour y parvenir :

1. Utiliser PDO (pour tout pilote de base de données pris en charge) :

$req = $pdo->prepare('SELECT * FROM students WHERE name = :name') ;
$req->execute([ 'name' => $name ]) ;

foreach ($req as $row) {
    // Fait quelque chose avec $row
}

2. Utilisation de MySQLi (pour MySQL) :
Depuis PHP 8.2+, nous pouvons utiliser execute_query() qui prépare, lie les paramètres et exécute la requête SQL en une seule méthode :

$result = $db->execute_query('SELECT * FROM employees WHERE name = ?', [$name]) ;
 while ($row = $result->fetch_assoc()) {
     // Faire quelque chose avec $row
 }

Jusqu'à la version PHP8.1 :

$req = $db->prepare('SELECT * FROM employees WHERE name = ?') ;
 $req->bind_param('s', $name) ; // 's' spécifie le type de la variable => 'string'
 $req->execute() ;
 $result = $req->get_result() ;
 while ($row = $result->fetch_assoc()) {
     // Faire quelque chose avec $row
 }

Si vous vous connectez à une base de données autre que MySQL, il existe une deuxième option spécifique au pilote à laquelle vous pouvez vous référer (par exemple, pg_prepare() et pg_execute() pour PostgreSQL). PDO est l'option universelle.

Configurer correctement la connexion

PDO

Notez que lorsque vous utilisez PDO pour accéder à une base de données MySQL, les véritables requêtes préparées ne sont pas utilisées par défaut. Pour y remédier, vous devez désactiver l'émulation des instructions préparées. Voici un exemple de création d'une connexion à l'aide de PDO :

$dbConnection = new PDO('mysql:dbname=nomdelaBDD;host=127.0.0.1;charset=utf8mb4', 'utilisateur', 'motDePasse2000');

$dbConnection->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$dbConnection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

Dans l'exemple ci-dessus, le mode d'erreur n'est pas strictement nécessaire, mais il est conseillé de l'ajouter. De cette manière, PDO vous informera de toutes les erreurs MySQL en lançant une exception PDO.

Ce qui est obligatoire, en revanche, c'est la première ligne setAttribute(), qui indique à PDO de désactiver les requêtes préparées émulées et d'utiliser de vraies requêtes préparées. Cela permet de s'assurer que la requête et les valeurs ne sont pas analysées par PHP avant d'être envoyées au serveur MySQL (ce qui ne permet pas à un éventuel attaquant d'injecter du code SQL malveillant).

Bien que vous puissiez définir le charset dans les options du constructeur, il est important de noter que les « anciennes » versions de PHP (avant la version 5.3.6) ignoraient silencieusement le paramètre charset dans la DSN.

Si vous voulez savoir comment procéder pour une insertion, voici un exemple en utilisant PDO :

$reqPreparee = $db->prepare('INSERT INTO table (colonne) VALUES (:colonne)');
$reqPreparee->execute([ 'column' => $valeurNonSure]);

Mysqli

Pour mysqli, nous devons suivre la même routine :

mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT) ; // rapport d'erreur
$dbConnection = new mysqli('127.0.0.1', 'username', 'password', 'test') ;
$dbConnection->set_charset('utf8mb4') ; // charset

Pour réécrire votre exemple avec mysqli, nous aurions besoin de quelque chose comme ce qui suit :

prepare(« INSERT INTO table (column) VALUES ( ?) ») ;
$stmt->bind_param(« s », $variable) ; // « s » signifie que la base de données attend une chaîne de caractères
$stmt->execute() ;

La fonction clé que vous voudrez lire est mysqli::prepare.

Explication sur les requêtes préparées PHP - MySQL

La déclaration SQL que vous transmettez à prepare est analysée et compilée par le serveur de base de données. En spécifiant des paramètres (soit un ?, soit un paramètre nommé comme :name dans l'exemple ci-dessus), vous indiquez au moteur de base de données les éléments sur lesquels vous souhaitez effectuer un filtrage. Ensuite, lorsque vous appelez execute, l'instruction préparée est combinée avec les valeurs des paramètres que vous avez spécifiées.

Ce qui est important ici, c'est que les valeurs des paramètres sont combinées avec l'instruction compilée, et non avec une chaîne SQL. L'injection SQL fonctionne en incitant le script à inclure des chaînes malveillantes lorsqu'il crée le code SQL à envoyer à la base de données. En envoyant le code SQL proprement dit séparément des paramètres, vous limitez donc le risque de vous retrouver avec quelque chose que vous n'aviez pas prévu.

Tous les paramètres que vous envoyez lors de l'utilisation d'une instruction préparée seront traités comme des chaînes de caractères (bien que le moteur de la base de données puisse procéder à une certaine optimisation, de sorte que les paramètres peuvent également se retrouver sous la forme de nombres, bien entendu). Dans l'exemple ci-dessus, si la variable $name contient 'Sarah' ; DELETE FROM employees, le résultat sera simplement une recherche de la chaîne « “Sarah” ; DELETE FROM employees », et vous ne vous retrouverez pas avec une table vide.

Un autre avantage de l'utilisation des instructions préparées est que si vous exécutez la même instruction plusieurs fois dans la même session, elle ne sera analysée et compilée qu'une seule fois, ce qui vous permet de gagner en rapidité.

Les instructions préparées peuvent-elles être utilisées pour les requêtes dynamiques ?

Bien que vous puissiez toujours utiliser des instructions préparées pour les paramètres de la requête, la structure de la requête dynamique elle-même ne peut pas être paramétrée et certaines fonctionnalités de la requête ne peuvent pas être paramétrées.

Pour ces scénarios spécifiques, la meilleure chose à faire est d'utiliser un filtre de liste blanche qui restreint les valeurs possibles.

// Liste blanche de valeurs
// $dir ne peut être que 'DESC', sinon il sera 'ASC'
if (empty($dir) || $dir !== 'DESC') {
   $dir = 'ASC' ;
}

Jusque là les solutions que nous avons proposées ne couvrent qu'une partie du problème. En fait, il existe quatre parties de requête différentes que l'on peut ajouter dynamiquement à SQL :

  • une chaîne de caractères
  • un nombre
  • un identificateur
  • un mot-clé syntaxique

Les instructions préparées ne couvrent que deux d'entre elles. Mais il arrive que nous devions rendre notre requête encore plus dynamique, en y ajoutant des opérateurs ou des identificateurs. Nous aurons donc besoin de différentes techniques de protection.

En général, une telle approche de protection est basée sur la liste blanche comme nous l'avons vu précédemment. Dans ce cas, chaque paramètre dynamique doit être codé en dur dans votre script et choisi parmi cet ensemble. Par exemple, pour effectuer un tri dynamique nous pouvons faire :

$ordres = array(« nom», « prix», « qte ») ; // Nom des champs
$cle = array_search($_GET['tri'], $ordres)) ; // si nous avons un tel tri dans notre liste précédente
$orderby = $orders[$cle] ; // Si ce n'est pas le cas, le premier sera défini automatiquement. 
$req = « SELECT * FROM `table` ORDER BY $orderby » ; // La valeur est sûre

Il existe un autre moyen de sécuriser les identifiants - l'échappement - mais je préfère m'en tenir à la liste blanche, qui constitue une approche plus solide et plus explicite. Cependant, tant que vous avez un identifiant entre guillemets, vous pouvez échapper avec des guillemets pour le sécuriser. Par exemple, par défaut, pour mysql, vous devez doubler les guillemets pour l'échapper. Pour d'autres SGBD, les règles d'échappement sont différentes.

NB : Il y a tout de même un problème avec les mots-clés de la syntaxe SQL (tels que AND, DESC et autres), mais la mise en liste blanche semble être la seule approche dans ce cas.

Recommandation pour lutter contres les injections SQL

Une recommandation générale peut donc être formulée comme suit

  • Toute variable représentant une donnée littérale SQL (ou, pour simplifier, une chaîne SQL ou un nombre) doit être ajoutée au moyen d'une requête préparée. Il n'y a pas d'exception.
  • Toute autre partie de la requête, telle qu'un mot-clé SQL, un nom de table ou de champ, ou un opérateur, doit être filtrée par une liste blanche.

Conclusion

Bien qu'il y ait un accord général sur les meilleures pratiques concernant la protection contre les injections SQL, il y a encore beaucoup de mauvaises pratiques. Et certaines d'entre elles sont trop profondément ancrées dans l'esprit des utilisateurs de PHP. Parmis elles, nous pouvons citer le formatage manuel des chaînes de caractères qui ont comme principaux inconvénients : s'appliquent qu'aux chaînes de caractères et, comme c'est manuel, il s'agit essentiellement d'une mesure facultative, non obligatoire, sujette à toutes sortes d'erreurs humaines.

Je pense que tout cela est dû à une très vieille superstition, soutenue par des autorités telles que l'OWASP ou le manuel PHP, qui proclame l'égalité entre l'« échappement » et la protection contre les injections SQL.

Indépendamment de ce que dit le manuel de PHP depuis des lustres, *_escape_string ne sécurise en aucun cas les données et n'a jamais été destiné à le faire. En plus d'être inutile pour toute partie de SQL autre qu'une chaîne, l'échappement manuel est erroné, parce qu'il est manuel, à l'opposé d'un procédé automatique.

Et l'OWASP aggrave encore la situation en insistant sur l'échappement de l'entrée utilisateur, ce qui est totalement absurde : il ne devrait pas y avoir de tels mots dans le contexte de la protection contre les injections. Toute variable est potentiellement dangereuse, quelle qu'en soit la source ! Ou, en d'autres termes, toute variable doit être correctement formatée pour être introduite dans une requête, quelle qu'en soit la source. C'est la destination qui compte. Dès qu'un développeur commence à séparer les moutons des chèvres (en se demandant si telle ou telle variable est « sûre » ou non), il fait un premier pas vers le désastre. Sans parler du fait que même la formulation suggère un échappement en bloc au point d'entrée, ressemblant à la fonction magic_quotes qui fut méprisée, dépréciée et supprimée par la communauté PHP.

Ainsi, contrairement à tout « échappement », les reqûetes préparées sont la mesure qui protège effectivement de l'injection SQL (lorsqu'elle est applicable).