Dans un site web, tout est axé autour du traitement des données. Que ce soit des données issues d'une base de données ou les données soumises par les internautes eux-même par l'intermédiaire de formulaires. Bien protéger ses données c'est assurer une certaine sécurité globale.
Une architecture 3-tier et plus généralement nn-tier est un système composé de n couches. Dans le cas du Web, on parle souvent d'architecture 3-tier ou 3 étages. En fait, on distingue :
d'une part, le navigateur de l'internaute (Firefox, IE, Safari, ...) qui a la charge d'afficher correctement les données transmises par le serveur,
la couche serveur avec PHP par exemple (notez que ça pourrait très bien être un autre langage comme Python) qui traite les données à la fois venant du navigateur mais aussi celles issues de la BDD (c'est en quelque sorte le messager qui s'assure du transfert des données),
l'étage qui permet le stockage et la conservation des informations : c'est la BDD (exemple : MySQL, PostGreSQL...).
.
Schéma
Les flèches bleues représentent les flux de données qui transitent depuis l'internaute jusqu'à la base de données et les flèches vertes les données transitant depuis la base de données jusqu'au navigateur de l'internaute. Ces transferts de données sont schématisés par les flèches rouges.
Sécurisons nos données
Nous avons donc deux types de flux de données à sécuriser.
Les données entrantes : dans un premier temps, nous allons devoir nous occuper de sécuriser les données qui proviennent du membre et donc de formulaires avant de pouvoir les entrer dans la BDD.
Les données sortantes : puis, nous verrons comment sécuriser l'affichage des données issues de la BDD.
L'attaque par injection SQL est très fréquente car elle est rapide à mettre en place, peut occasionner des dégâts irréversibles dans votre base de données ou, si elle est utilisée de manière plus subtile, elle permet de récupérer en toute discrétion les mots de passe et identifiants. Le pirate détourne votre requête en injectant du code dans les champs du formulaire : d'où le terme d'injection SQL.
Détournement de clause WHERE
On a l'habitude de formater nos requêtes SQL de cette manière :
<?php
$requete = "SELECT * FROM membre
WHERE pseudo = '".$_GET['pseudo']."'
AND password = '".$_GET['password']."' ";
?>
Notez que j'ai utilisé $_GET mais que l'injection SQL fonctionne tout aussi bien avec $_POST : ne vous croyez donc pas à l'abri en mettant method = "post" en attribut de vos balises de formulaire. :p C'est souvent une idée préconçue mais qui s'avère totalement fausse car il est fort possible de modifier les en-têtes HTTP pour transmettre des données sous forme $_POST, donc méfiez-vous !
Je ne vois pas où est le problème avec cette requête ?
Ne vous inquiétez pas : vous n'allez pas tarder à le voir. Imaginons un instant que je transmette ceci :
<?php
$_GET['password'] = " ' OR 1 = '1 ";
?>
Ma requête devient :
<?php
$requete = "SELECT * FROM membre
WHERE pseudo = '".$_GET['pseudo']."'
AND password = '' OR 1 = '1' ";
?>
Ainsi, vous pouvez entrer n'importe quel mot de passe, il sera toujours valide puisque vous admettrez que 1 est toujours égal à 1. ^^ Il suffit donc de fournir un pseudo valide et la requête est vraie ! De cette manière, il est possible de récupérer facilement les informations sur les membres du site.
Détournement de la clause DELETE
Vous commencez à avoir peur ? C'est normal : ça m'a fait tout drôle à moi aussi de comprendre tout à coup que tout mon site web était rempli de failles avant que j'applique les quelques règles de sécurité que je vais vous fournir. Mais attendez de voir ce que nous réserve le détournement d'une clause DELETE : c'est peut-être ce qu'il y a de pire !
Prenons un exemple de requête :
<?php
$requete = "DELETE FROM membre
WHERE id = '".$_GET['id']."' ";
?>
Voilà ce que je peux transmettre en paramètre :
<?php
$_GET['id'] = "1 ' OR id > '0 ";
?>
La requête donne donc :
<?php
$requete = "DELETE FROM membre
WHERE id = '1' OR id > '0' ";
?>
Cette requête détruira donc toutes les entrées contenues dans votre table membre ! Vous aurez perdu tous vos membres en 10 ms montre en main. :waw: Et encore, cette requête pourrait être beaucoup, beaucoup plus dangereuse.
Les requêtes multiples : mysqli_multi_query()
L'interface mysqli propose d'exécuter plusieurs requêtes en une seule. Je vous conseille très vivement de ne jamais le faire à moins d'être totalement sûr que les paramètres envoyés seront valides !
Pourtant, c'est cool de pouvoir envoyer plusieurs requêtes en une, ça permet d'aller plus vite pour traiter les résultats. :euh: Quoi, j'ai dit une bêtise ?
Certes, il y a quelques avantages pour le traitement des résultats. Cependant, du point de vue de la sécurité, elles sont à proscrire !
Prenons un exemple concret :
<?php
$requete = "SELECT pseudo FROM membre
WHERE id = '".$_GET['id']."' ";
?>
Voilà le paramètre que nous pouvons transmettre :
<?php
$_GET['id'] = "1'; DROP TABLE membre ";
?>
Notre requête devient :
<?php
// Connexion à la BDD
$link = mysqli_connect("localhost", "my_user", "my_password", "world");
// Formatage de la requête
$requete = "SELECT pseudo FROM membre
WHERE id = '1'; DROP TABLE membre ";
// Exécution de la requête
mysqli_multi_query($link, $requete) or exit(mysqli_error);
?>
Boom ! Votre table membre vient de rendre l'âme. :ange:
Cette petite partie introductive nous aura permis de faire une initiation aux dangers des attaques par injection SQL et nous allons à présent voir comment s'en protéger efficacement !
Les magic quotes : la fausse bonne idée...
Il nous faut donc protéger les données issues du formulaire avant de les entrer dans notre requête sinon on s'expose à de gros risques... Nous devons échapper nos valeurs pour les rendre inertes et sans danger. Les magic_quotes font partie d'une directive de PHP visant à assurer la sécurité des requêtes SQL à son insu en échappant systématiquement les caractères suivant :
les guillemets simples ' ;
les guillemets doubles " ;
les slashes / ;
les caractères NULL.
Pour échapper un caractère, cette directive ajoute des antislashes dans les chaînes qui transitent vers le script PHP. En fait, elle joue le même rôle que la fonction addslashes().
Regardons ce que donne l'activation de cette directive ensemble :
<?php
$requete = "SELECT pseudo FROM membre
WHERE id = '".$_GET['id']."' ";
?>
L'ajout du caractère d'échappement permet d'utiliser certains caractères dans notre requête sans qu'elle ne se transforme pour autant en injection.
<?php
$requete = "SELECT pseudo FROM membre
WHERE id = '\"1\"mettez l'injection que vous voulez ici\"' ";
?>
Cette directive a donc la capacité de bloquer certaines injections SQL mais elle reste très problématique avec l'utilisation d'XHTML. Ceux qui ont déjà connu cette directive se souviendront sûrement de chaînes comme celles-là :
J\\\'aime le site du zer0
Pour éliminer ces barres obliques inverses, on a souvent recours à la fonction stripslashes() mais il est souvent fastidieux de nettoyer toutes les variables de PHP avant de les afficher. Par ailleurs, je ne vous l'ai pas encore dit mais les magic_quotes ne protègent pas contre toutes les injections SQL car certaines ne nécessitent pas de guillemets.
Bref, je vous invite à les désactiver au plus vite (de toute façon, avec l'avènement de PHP 6, il est très probable qu'elles disparaissent à jamais :D ).
Je sécurise, tu sécurises, nous sécurisons !
Renvoyez donc au placard les guillemets magiques et la fonction addslashes() !
Nous allons maintenant parler d'une fonction bien pratique : mysql_real_escape_string().
En gros, elle neutralise tous les caractères susceptibles d'être à l'origine d'une injection SQL. À partir de maintenant, utilisez toujours cette fonction pour sécuriser les chaînes transmises à vos requêtes. Par ailleurs, il faut aussi que vos données soient placées dans des guillemets (simples ou doubles) sinon, il est possible de réinjecter du code (notamment des sous-requêtes grâce aux parenthèses).
Exemple :
<?php
$pseudo = mysql_real_escape_string($_POST['pseudo']);
$requete = "SELECT * FROM membre WHERE pseudo = '$pseudo' ";
?>
Le caractère % est souvent utilisé dans MySQL avec la clause LIKE. Ce caractère est un joker qui représente n'importe quelle autre chaîne.
SELECT id, pseudo, password FROM membre WHERE pseudo LIKE 'a%'
Cette requête récupère toutes les informations sur les membres dont le pseudo commence par un a.
Cette fonction souvent méconnue offre la possibilité de se protéger contre le type d'injection soulevée auparavant. La chaîne à échapper est le premier paramètre à passer à la fonction. Puis, on entre les caractères qui doivent être échappés.
Je vous propose une fonction qui sécurise vos données avant de les passer dans votre requête. Centraliser le traitement est une bonne habitude à acquérir dès le début. ^^
<?php
function securite_bdd($string)
{
// On regarde si le type de string est un nombre entier (int)
if(ctype_digit($string))
{
$string = intval($string);
}
// Pour tous les autres types
else
{
$string = mysql_real_escape_string($string);
$string = addcslashes($string, '%_');
}
return $string;
}
?>
Notre fonction securite_bdd() prend un paramètre : une chaîne de caractères quelconque. Si cette chaîne n'est composée que de nombres, on force la conversion explicite en un entier avec la fonction intval(). Autrement, on échappe la chaîne avec la fonction mysql_real_escape_string() puis addcslashes().
Maintenant, je veux et j'exige qu'à chaque fois que vous récupérez une donnée issue d'un formulaire ou d'une URL, vous lui appliquiez cette fonction avant de faire quoi que ce soit. ^^
Exemple :
<?php
// Sécurisation des variables
$pseudo = securite_bdd($_GET['pseudo']);
$password = securite_bdd($_GET['password']);
// Formatage de la requête
$requete = "SELECT * FROM membre
WHERE pseudo = '$pseudo'
AND password = '$password' ";
// Envoi au serveur en toute sécurité ^^
mysql_query($requete) or exit(mysql_error());
?>
Maintenant, toutes les données qui entrent dans votre BDD doivent être sécurisées de cette manière.
Les requêtes préparées
Les requêtes préparées sont une innovation majeure apportée notamment par MySQLi. Concrètement, elles permettent une augmentation de la sécurité et, dans certains cas, un gain de performances.
En tant que programmeur, vous savez (ou vous vous doutez) qu'il existe plusieurs types de variables :
les nombres entiers int ;
les nombres réels float/double ;
les chaînes de caractères string ;
etc.
La préparation de nos requêtes nous permettra de fixer le type de variable qui doit entrer dans notre requête. De cette manière, la majeure partie des injections SQL sera impossible ! :D
Utilisons l'interface MySQLi pour comprendre le mécanisme de la préparation de requêtes. Il faut bien entendu suivre un processus assez fixe :
init() : création d'un objet commande et association à la connexion ;
prepare() : préparation de la requête avec utilisation des "trous" (placeholder), analyse de la requête puis compilation ;
bind_param() : liaison des paramètres dans l'ordre des marqueurs ;
execute() : exécution de la requête avec la valeur des paramètres envoyés ;
store_result() : transmission de l'intégralité des résultats ;
bind_result() : liaison des résultats à des variables PHP ;
fetch() : équivalent de mysqli_fetch_array() ;
close() : fermeture de la commande préparée.
Ne vous inquiétez pas, je vais tout vous expliquer. ^^ Nous allons employer le style de programmation procédurale pour la compréhension de tous, mais sachez qu'en orienté objet c'est le même mécanisme.
Formatons notre requête SQL :
<?php
$requete = "SELECT id, pseudo, password
FROM membre
WHERE pseudo = ?
AND password = ? ";
?>
Elle est pas un peu bizarre ta requête là ? o_O
Je vous l'accorde. :) La seule différence avec une requête non préparée, c'est la présence de marqueurs ?. C'est un marqueur de paramètre qui s'utilise comme une variable.
Voyons à présent le processus de commande préparée :
<?php
// Connexion à la BDD
$connexion = mysqli_connect("localhost", "my_user", "my_password", "world");
// Création de l'objet commande
$prepa = mysqli_stmt_init($connexion);
// Préparation de la requête : envoi à la base
mysqli_stmt_prepare($prepa, $requete);
?>
Récupérons à présent les données issues du formulaire :
Maintenant, il ne nous reste plus qu'à forcer le typage de nos variables et à les transmettre à la requête. Nous allons utiliser des lettres qui correspondent à un type particulier de variable. Je vais vous présenter les lettres principales que nous utiliserons :
Lettre
Type
s
string
i
int
b
BLOB
Nous allons passer en paramètres les variables en forçant leur type :
Expliquons le code ci-dessus. Tout d'abord, on passe en paramètre notre requête préparée $prepa puis on indique le type de variables qu'elle doit recevoir. Nous utilisons deux fois la lettre s car on transmet la variable $pseudo et $password qui doivent correspondre à des chaînes de caractères.
Nous devons encore exécuter la requête et traiter les résultats :
<?php
// Exécution de la requête
$resultat = mysqli_stmt_execute($prepa);
// Récupération des résultats
mysqli_stmt_store_result($prepa);
// On lie les résultats à des variables PHP
mysqli_stmt_bind_result($prepa, $id, $pseudo, $password);
// Affichage des résultats
mysqli_stmt_fetch($prepa);
echo 'Salut ' . htmlentities($pseudo);
echo 'ID : ' . $id;
echo 'Password ' . $password;
// Fermeture de la requête
mysqli_stmt_close($prepa);
?>
Récapitulation de la procédure :
<?php
// Formatage de la requête
$requete = "SELECT id, pseudo, password
FROM membre
WHERE pseudo = ?
AND password = ? ";
// Connexion à la BDD
$connexion = mysqli_connect("localhost", "my_user", "my_password", "world");
// Création de l'objet commande
$prepa = mysqli_stmt_init($connexion);
// Préparation de la requête : envoi à la base
mysqli_stmt_prepare($prepa, $requete);
// Récupération des variables
$pseudo = $_GET['pseudo'];
$password = $_GET['password'];
// Transfert des paramètres
mysqli_stmt_bind_param($prepa, 'ss', $pseudo, $password);
// Exécution de la requête
$resultat = mysqli_stmt_execute($prepa);
// Récupération des résultats
mysqli_stmt_store_result($prepa);
// On lie les résultats à des variables PHP
mysqli_stmt_bind_result($prepa, $id, $pseudo, $password);
// Affichage des résultats
mysqli_stmt_fetch($prepa);
echo 'Salut ' . htmlentities($pseudo);
echo 'ID : ' . $id;
echo 'Password ' . $password;
// Fermeture de la requête
mysqli_stmt_close($prepa);
?>
Vous le voyez, c'est quand même assez fastidieux d'écrire une requête préparée. Mais rappelez-vous que c'est une des méthodes les plus sûres pour envoyer des requêtes au serveur. ^^ Pour une utilisation plus facile des requêtes préparées, je recommande PDO (PHP Data Object) mais le principe reste le même.
Pour les curieux, voilà comment on prépare et on exécute une requête avec PDO :
<?php
// $dbh correspond à une connexion avec une base de données (MySQL, PostgreSQL, Oracle...)
$stmt = $dbh->prepare("INSERT INTO REGISTRY (name, value) VALUES (:name, :value)");
$stmt->bindParam(':name', $name);
$stmt->bindParam(':value', $value);
// Insertion d'une ligne
$name = 'one';
$value = 1;
$stmt->execute();
?>
Il existe un autre grand type d'injection connue des pirates, c'est l'injection HTML ou plus communément appelée Cross-Site Scripting (XSS).
Pourquoi XSS ? Ça ne devrait pas plutôt être CSS pour CCross-Site Scripting ?
Eh bien si, mais figurez-vous que l'acronyme CSS est déjà utilisé pour Cascading Style Sheet. Ce sont les feuilles de styles qui permettent la mise en place du design d'un site web. On a donc pensé à remplacer le C par un X histoire de ne pas confondre. De plus, X symbolise une croix qui se traduit par cross en anglais.
Attention au danger des XSS !
Si vos flux de données ne sont pas protégés à l'affichage, les pirates peuvent entrer des balises nocives et notamment du JavaScript. En bidouillant un peu, on peut facilement injecter de nouvelles balises et donc réécrire la page XHTML à notre guise. C'est pour ça qu'en français, on traduit l'acronyme XSS par injection HTML.
Et alors, c'est pas dangereux de mettre en italique à ce que je sache ?
Certes, la balise <i> n'est pas nocive mais si on peut inclure une balise HTML, pourquoi ne pourrait-on pas en inclure d'autres ? Il est très simple de charger un script externe pirate depuis cette page avec un soupçon de JavaScript :
Pour éviter les injections de ce type, la meilleure solution est de rendre toutes les balise XHTML inopérantes avec la fonction htmlentities(). Reprenons l'exemple précédent :
La fonction htmlentities() a remplacé dans la chaîne tous les caractères possibles en leur équivalent HTML. L'injection HTML devient dès lors impossible.
htmlspecialchars() est une version moins complète de htmlentities(). Attention, je ne dis pas qu'elle est moins sécuritaire ! En effet, si les charset (exemple : UTF-8) sont convenablement maîtrisés entre la base de données et la page XHTML, elle est tout aussi performante que son homologue htmlentities(). Cette fonction remplace les caractères suivants par leur entité HTML :
Les délimiteurs de balise < et > ;
Les guillemets simples ' ;
Les guillemets doubles " ;
Le "et commercial" &.
Je vous conseille donc fortement d'utiliser htmlentities() plutôt que htmlspecialchars() si vous n'êtes pas certain de maîtriser vos charsets.
Nous avons donc d'une part notre fonction securite_bdd() qui se charge de contrôler les chaînes transmises à la BDD et htmlentities() qui s'occupe de gérer la sécurité à l'affichage. Ces deux fonctions servent toutes les deux à sécuriser les flux de données, alors pourquoi ne pas les regrouper ? C'est ce que nous allons faire : créons une classe Sécurité qui centralisera nos deux fonctions.
Création de notre classe
<?php
class Securite
{
// Données entrantes
public static function bdd($string)
{
// On regarde si le type de string est un nombre entier (int)
if(ctype_digit($string))
{
$string = intval($string);
}
// Pour tous les autres types
else
{
$string = mysql_real_escape_string($string);
$string = addcslashes($string, '%_');
}
return $string;
}
// Données sortantes
public static function html($string)
{
return htmlentities($string);
}
}
?>
Un des grands principes de la sécurité repose sur la centralisation des traitements de données. À présent, avant d'envoyer une chaîne dans votre requête, vous ferez :
<?php
$pseudo = Securite::bdd($_POST['pseudo']);
$password = Securite::bdd( $_POST['password'] );
$requete = "SELECT * FROM membre
WHERE pseudo = '$pseudo' AND password = '$password' ";
?>
Et pour afficher du texte qui provient de l'internaute ou de la base de données :
Une liste blanche correspond à l'ensemble des données que l'internaute a le droit de rentrer. Instaurer une liste blanche permet un contrôle total sur les données transmises.
Par exemple, si vous proposez lors d'une inscription de choisir le pays d'origine, vous pouvez facilement vérifier si le pays est valide ou non. On implémente souvent ce type de liste avec les tableaux en PHP.
<?php
$pays_transmis = $_POST['pays'];
// Vérification de la présence du pays dans la liste
if(in_array($pays_transmis, $liste_pays))
{
echo 'pays valide.';
}
else
{
echo 'pays invalide, veuillez ressaisir votre pays. ';
}
?>
Bien entendu, les tableaux ne sont pas la meilleure solution à adopter dans tous les cas. La base de données peut s'avérer plus efficace pour stocker un plus grand nombre de valeurs autorisées. En effet, créer un tableau composé de plus de 500 valeurs devient assez lourd à gérer.
Limites de la liste blanche
Cette technique de contrôle de données est particulièrement efficace mais peut se révéler très vite complexe à gérer. En effet, comment contrôler un champ prénom ou nom ? Il existe plusieurs milliers de prénoms et ne parlons pas des noms ! La liste blanche s'avère alors très dure à mettre en place.
Liste noire
Définition et exemple
Contrairement à la liste blanche, la liste noire filtre les données interdites. On autorise donc tout, sauf certaines valeurs que l'on aura précisé. La liste noire permet généralement de lutter contre le spam et les robots. En effet, le but du spam est de promouvoir un produit ou un service en envoyant le plus de messages possibles et de manière à ce qu'ils soient visibles par le plus grand nombre. Vos forums ont peut-être déjà été la cible du spam et une bonne solution est de mettre en place une liste noire.
Mettons en place une liste noire qui filtre les insultes :
<?php
// Récupération depuis une BDD
$message = $resultat['message'];
// Le message est considéré comme valide
$autorisation = true;
// On parcourt toutes les insultes de la liste noire
foreach($liste_insulte as $insulte)
{
// Si une insulte est comprise dans le message
if(stripos($message, $insulte) !== false)
{
$autorisation = false;
break;
}
}
?>
On obtient une variable $autorisation qui est un booléen (true ou false).
Prototype de la fonction stripos() :
int stripos ( string $haystack , string $needle [, int $offset ] )
Limites de la liste noire
La liste noire est une bonne alternative à la liste blanche. Cependant, il faut toujours la mettre à jour pour contrer les nouveaux messages de spam. Par exemple, le mot cr@pule n'est pas filtré par notre liste noire ; tout comme le mot CRAPUL3. Il faut donc prendre en compte tous ces changements pour avoir une liste noire fiable.
Liste grise
Du noir et du blanc, ça donne... du gris ! :magicien: La meilleure technique pour filtrer les données est d'utiliser en complément la liste blanche et la liste noire. Je vous recommande donc de préparer, pour les cas où c'est possible, deux listes différentes :
une liste blanche qui autorise seulement des valeurs données ;
une liste noire qui filtre les données incorrectes.
En effet, pour filtrer un champ pseudo par exemple je ne vois pas comment écrire une liste blanche donc dans ce cas la liste noire est préconisée :p . En revanche pour gérer un sondage (nombre de possibilités finie) il est recommandé d'instaurer une liste blanche. Ainsi si vous arrivez à gérer parfaitement les différents cas votre application sera contrôlée par un ensemble de listes blanches et noires et donc des listes grises : le filtrage des données est considérablement augmenté !
J'espère que vous avez compris que vous deviez maitriser vos flux de données pour protéger correctement votre application web. Les failles XSS et les injections SQL peuvent facilement être évitées comme je vous l'ai brièvement expliqué.
Terminons par une petite synthèse de ce que vous devez absolument faire pour controler vos données :
Protéger les chaînes de caractères dans vos requêtes, la meilleure solution à ce jour étant la préparation de vos requêtes ;
Protéger les données affichées par le navigateur avec htmlentities() ;
Mettre en place un filtre systématique par liste grise.
Pour aller plus loin...
Voici quelques références incontournables pour approfondir vos connaissances :