Étape 4: Injection SQL
Introduction
Avant d’aller plus loin dans le développement de notre site Web dynamique il y a un point de vigilance à traiter au plus tôt: le problème des injections SQL.
Comme nous l’avons vu dans les étapes précédentes, le principe même du Web dynamique via des technos telles que HTML/PHP/SQL est que des données saisies dans des formulaires HTML soient envoyées au script PHP via des paramètres CGI, et que ce script “injecte” les données reçues dans des requêtes SQL qui, au niveau PHP, ne sont que de simples chaînes de caractères. Ces requêtes SQL sont ensuite exécutées sur la base de données.
À l’étape précédente notre script PHP avait utilisé la valeur du paramètre CGI prof
dans sa requête SQL pour ne récupérer que les créneaux de l’enseignant dont le formulaire avait envoyé l’identifiant.
$sql = "select * from planning where (numprof={$prof}) order by debut;";
Cette ligne de code ne fait qu’un simple copier/coller de la valeur de la variable $prof
dans la chaîne de caractères. D’où la tentation d’y passer “autre chose” que l’identifiant d’un enseignant… ☺
Exemple d’injection SQL
Nous savons faire un formulaire que demanderait, via un widget texte, la saisie de l’identifiant d’un enseignant. Au lieu de saisir une valeur telle que 6
, essayons la chaîne de caractères suivante: -1) union select * from planning where (1=1
Lorsque le script PHP utilise cette chaîne de caractères en guise de valeur pour la variable $prof
quand il construit sa requête SQL (une chaîne de caractères également), la substitution donne le résultat suivant:
select * from planning where (numprof=-1) union select * from planning where (1=1) order by debut;
Peut importe le résultat de la requête à gauche de l’opérateur union
, la requête de droite retourne la totalité de la table planning
!
Bon, cet exemple d’injection SQL est plutôt gentillet: nous nous sommes limités à injecter des commandes select
supplémentaires. Mais jous aurions pu y injecter, exactement de la même façon, des instruction SQL update
, delete
ou même drop table
… ☹
Comment s’en protéger ?
Si on regarde d’un peu plus près les chaînes de caractères qui servent à réaliser des injections SQL, on se rend vite compte qu’il y a des caractères qui n’ont rien à y faire quand on attend des valeurs numériques ou textuelles. C’est le cas des parenthèses, des apostrophes, des guillemets, etc. Il nous faut donc “nettoyer” les données que l’on reçoit avant de les utiliser.
L’idée n’est pas de supprimer brutalement ces caractères. Si un formulaire propose un champ texte libre pour saisir une adresse postale, il peut être tout à fait légitime que cette valeur contienne des apostrophes. De la même façon, si nous saisissons la description d’un article à vendre dans un formulaire, nous allons vraisemblablement utiliser des parenthèses, des double points, des points virgule, etc. L’objectif sera donc de “neutraliser” ces caractères afin qu’ils ne soient pas reconnus comme des caractères syntaxiques par l’interpréteur SQL qui exécutera la requête, mais qu’ils puissent néanmoins être utilisés en tant que caractères textuels dans des valeurs d’attributs.
Pour ce faire, il y a 2 approches:
- soit on nettoie manuellement chaque chaîne de caractères en invoquant une fonction adéquate avant de construire la requête SQL
- soit on prépare la requête SQL via une sorte de template; on passe alors ce template avec la liste des valeurs (non nettoyées) au module SQL qui se chargera lui-même de nettoyer chaque valeur avant de l’insérer dans le template
- soit les deux simultanément… ☺
Nettoyage manuel (obsolète)
Dans les anciennes versions des API SQLite (idem pour les autres bases de données), on utilisait des fonctions sur les chaînes de caractères pour neutraliser certains caractères. Notamment les apostrophes.
$prof = $_GET['prof'];
$prof = SQLite3::escapeString($prof);
$sql = "select * from planning where (numprof={$prof}) order by debut;";
Malheureusement, l’injection indiquée précédemment (avec le
union
) n’est pas bloquée par cette fonction ☹
Une autre solution consiste à utiliser la méthode PDO::quote()
pour encadrer la chaîne de caractères avec des apostrophes afin de limiter l’interprétation des autres caractères “bizarres” qui pourraient s’y trouver.
$dbh = new PDO('sqlite:planning.db') or die("impossible d'ouvrir la base sqlite");
$dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$prof = $_GET['prof'];
$prof = $dbh->quote($prof);
$sql = "select * from planning where (numprof={$prof}) order by debut;";
La requête SQL obtenue est la suivante qui, du coup, n’est pas correcte d’un point de vue syntaxique:
select * from planning where (numprof='-1) union select * from planning where (1=1') order by debut;
Commande prepare
Dans cette approche, la chaîne de caractères que nous allons péparer ne représente plus directement la requête SQL, mais plutôt un template dans lequel nous viendrons insérer, dans un deuxième temps, les valeurs des différents paramètres. Du coup, c’est le SGBD qui va se charger de nettoyer chaque valeur avant de l’insérer dans la requête (le statement).
$dbh = new PDO('sqlite:planning.db') or die("impossible d'ouvrir la base sqlite");
$dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Dans la requête SQL, on met un '?' à chaque endroit où l'on voudra insérer un paramètre
$sql = "select * from planning where (numprof=?) order by debut;";
// Préparation du statement avec la requête SQL
$stmt = $dbh->prepare($sql);
// Exécution du statement avec les paramètres indiqués dans le tableau
$stmt->execute([$prof]);
// Récupération du résultat de la requête
$res = $stmt->fetchAll();
// Parours du résultat
foreach ($res as $row) { [...) }
Fichier PHP complet pour contrer les injections SQL (fichier <code>index04d.php</code>)
<html>
<head>
<meta charset="utf-8"/>
<title>Formulaire HTML → PHP</title>
</head>
<body>
<h1>Formulaire HTML → PHP</h1>
<?php
$prof = $_GET['prof'];
echo "Vous avez demandé le planning de l'enseignant {$prof}.";
$dbh = new PDO('sqlite:planning.db') or die("impossible d'ouvrir la base sqlite");
$dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Dans la requête SQL, on met un '?' à chaque endroit où l'on voudra insérer un paramètre
$sql = "select * from planning where (numprof=?) order by debut;";
echo '<br>'.$sql;
// Préparation du statement avec la requête SQL
$stmt = $dbh->prepare($sql);
echo '<br>';
$stmt->debugDumpParams();
// Exécution du statement avec les paramètres indiqués dans le tableau
$stmt->execute([$prof]);
echo '<br>';
$stmt->debugDumpParams();
// Récupération du résultat de la requête
$res = $stmt->fetchAll();
echo '<table border="1">';
echo '<tr><th>num</th><th>début</th><th>fin</th><th>cours</th><th>salle</th><th>prof</th></tr>';
// Parcours du résultat
foreach ($res as $row) {
echo '<tr>';
echo '<td>'.$row['numcreneau'].'</td>';
echo '<td>'.$row['debut'].'</td>';
echo '<td>'.$row['fin'].'</td>';
echo '<td>'.$row['numcours'].'</td>';
echo '<td>'.$row['numsalle'].'</td>';
echo '<td>'.$row['numprof'].'</td>';
echo '</tr>';
}
echo '</table>';
?>
<hr>
Fin du script...
</body>
</html>
Conclusion
Vous savez maintenant comment interroger “proprement” une base de données SQLite depuis PHP via les PDO avec l’utilisation des statements et des commandes prepare
et execute
. De cette manière vos scripts PHP seront protégés contre les attaques par injection SQL “classiques”.
On peut aller beaucoup plus loin sur la manière de passer à la commande execute
un tableau de valeurs à insérer dans les paramètres de la requête SQL, mais cela dépasse le cadre de ce tutoriel.