logo du langage PHP Les bases du langage PHP

Chapitre 17 - Sécurité PHP

Documentation officielle

Remarque importante

Dans le chapitre précédent, j'ai détaillé la procédure pour envoyer et récupérer les données d'un formulaire. Mais tout transit de données (par $_POST comme $_GET) devra etre protégé. De l'erreur maladroite, mais surtout de l'acte de malveillance qui aura pour but de nuire à l'intégrité du site !

C'est un aspect très important, qui engage la responsabilité du développeur

Dans le cadre de la méthode POST, il s'agira de vérifier que le user n'envoie pas, par exemple a la place d'une adresse email valide, un prénom, des chiffres etc...

Illustration avec le code HTML ci-dessous, reprenant la phase d'inscription d'un utilisateur (au moment de renseigner son pseudo)


<label class="form-label" for="pseudo">Pseudo</label>
<input class="form-control" type="text" name="pseudo" id="pseudo" placeholder="Votre pseudo"
max-length="20" pattern="[a-zA-Z0-9-_.]{3,20}" title="caractères acceptés:
majuscules et minuscules, chiffres, signes tels que: - _ . , entre trois et vingt caractères."
required>

Je pourrai insérer dans mon formulaire, le pattern et son title tels que ci-dessus.

L'internaute qui ferait une erreur involontaire en ne respectant pas le cadre fixé, se verrait bloqué par le navigateur. Le pattern bloquant, et le title renseignant le user sur son erreur

Mais cela ne sera malheureusement nullement contraignant pour un individu "malveillant". Il lui suffira d'ouvrir sa console (F12), aller dans l'inspecteur, et supprimer dans le code ce pattern. Il pourra désormais entrer tout ce que bon lui semble, de farfelu ou dangereux, affectant le fonctionnement du site et/ou sa base de données

affichage du code sur la console

Toutes les contraintes faites coté Front/navigateur seront facilement contournables. Il faudra donc coder ces mêmes contraintes dans le script PHP

Voici a quoi ressemblerait la vérification pour le pseudo. La syntaxe que j'ai choisi étant une possibilité parmi d'autres, pour "cadrer" un pseudo


if($_POST){

  if(!isset($_POST['pseudo']) || !preg_match('#^[a-zA-Z0-9-_.]{3,10}$#',$_POST['pseudo'])){
    $erreur .= '<div class="alert alert-danger" role="alert">Erreur format pseudo !</div>';
  }
}

Avant tout, en ligne 1 je commence par vérifier si, globalement, j'ai bien reçu des données avec la méthode POST

Si j'ai plusieurs formulaires dans un même fichier, il faudra donner un nom a chacun d'eux, dans le name du bouton de validation. La syntaxe de la vérification globale deviendra if($_POST[nameDuButton]){}

Et ce n'est qu'ensuite que je commencerai à "bordurer" une par une, mes différentes contraintes

Notez que je vais vérifier les cas où la condition n'est pas remplie, plutot que de lister tous les cas où la condition est vérifiée

Il est plus sur et rapide de référencer les cas qui ne rentrent pas dans le cadre voulu, plutot que d'essayer de valider différentes configurations, difficiles a toutes recenser et imaginer

Ainsi, en premier, et cela sera le cas pour tous les champs suivants de mon formulaire, je vérifie que j'ai bien reçu une information dans mon champs ['pseudo']. Je vérifie avec un !isset, qui signifie "n'existe pas" ( !/not étant une négation). Si aucune donnée n'y a été insérée, alors je ne validerai pas le formulaire (si ma base de données exige que ce champs soit fourni)

En second lieu, je focalise sur la contrainte liée au champs ['pseudo'] a proprement parlé. Je la débute par un !preg_match. Ce dernier me permet de poser un cadre strict sur les caractères demandés, leur nombre etc... Si cela ne correspond pas au format que j'exige (ici, des lettres minuscules et majuscules, des chiffres, les signes - _ . , de trois caractères minimum et max dix), alors le formulaire ne sera pas validé. De plus, je génère un message au user pour lui signifier qu'il y a une erreur sur ce champs (message qui pourra etre plus précis que celui du dessus, très succin)

Je n'utiliserai pas tout le temps preg_match. Pour un nom, un prénom, une adresse, je passerai plutot par le controle de la longueur de la chaine de caractères avec iconv_strlen . D'autres développeurs utiliseront pour ce même controle strlen (voir le chapitre 8 sur les fonctions prédéfinies), les deux options sont possibles et cohérentes

En revanche, pour le code postal, je ferai à nouveau appel à preg_match, sachant que ce dernier, en France, devra obligatoirement etre fourni sous la forme de cinq chiffres


  if(!isset($_POST['code_postal']) || !preg_match('#^[0-9]{5}$#',$_POST['code_postal'])){
    + message en cas d'erreur;
  }

D'autres préfèreront cette syntaxe, avec ctype_digit (plutot que is_numeric, peut-etre moins adapté). Ainsi, je vérifie que je reçois bien un nombre entier, dont la longueur n'est pas différente de 5


if(!isset($_POST['code_postal']) || !ctype_digit($_POST['code_postal'])
|| iconv_strlen($_POST['code_postal']) !== 5){
    + message en cas d'erreur;
}

Voici un dernier exemple, car différent, pour controler que j'ai bien reçu un format valide pour une adresse email


  if(!isset($_POST['email']) || !filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)){
    $erreur .= '<div class="alert alert-danger" role="alert">Erreur format email !</div>';
  }

A nouveau, en tout premier, je vérifie que j'ai bien reçu une donnée dans le champs email, puis je controle son contenu. Cette fois, je vais utiliser filter_var en lui appliquant le format prévu par FILTER_VALIDATE_EMAIL, et cela sera suffisant pour que tout autre format qu'une adresse email soit refusé

Je précise que tous ces controles devront etre inclus dans ma condition de départ if($_POST)

Pour conclure, une fois vérifié que toutes mes conditions sont remplies. Qu'a aucun moment ma variable $erreur n'a reçu de contenu. Que if(empty($erreur) ), alors je pourrai commencer ma procédure de validation de formulaire, tout en continuant à sécuriser l'envoi de données, cette fois avec des requetes préparées

Documentation officielle

Faire une requete préparée à un double avantage

Elle ne sera préparée, analysée qu'une seule fois, mais exécutable autant de fois que l'on souhaite. Elle permet ainsi un gain de ressources et de vitesse d'exécution

Mais, ce qui va m'interesser dans ce sous-chapitre, c'est de sécuriser mon site en empechant les injections SQL

Une injection SQL se fait, par exemple, par le biais d'un formulaire d'inscription. Elle aura pour but de modifier la BDD (supprimer intégralement une table, donner des droits d'admin a un nouvel utilisateur etc...)

Faire une requete préparée est un des moyens pour les quasi annihiler

Voici deux exemples de syntaxes, la première, préparée, la seconde, avec un query, plus rapide, simple mais perméable. Je vais insérer deux données, une de type string et l'autre de type integer


if(empty($erreur)){
  $inscrireUser = $pdo->prepare("INSERT INTO membre (pseudo, code_postal) VALUES
  (:pseudo, :code_postal) ;");
  $inscrireUser->bindValue(':pseudo', $_POST['pseudo'], PDO::PARAM_STR);
  $inscrireUser->bindValue(':code_postal', $_POST['code_postal'], PDO::PARAM_INT);
  $inscrireUser->execute();
}

La même, avec query


if(empty($erreur)){
  $inscrireUser = $pdo->query("INSERT INTO membre (pseudo, code_postal) VALUES ($_POST[pseudo],
  $_POST[code_postal]) ;");
}

La syntaxe détaillée

Dans les deux cas, je commence par vérifier que ma variable $erreur n'a pas reçu de contenu ( if(empty($erreur)) )

Ensuite, pour ma requete préparée, je vais faire appel à ce que l'on appelle un marqueur nommé : pour ajouter les différents parametres/données ( :pseudo et :code_postal )

A la ligne suivante, dans mon bindValue, je vais faire correspondre pour chaque marqueur, sa valeur reçue dans le formulaire. Je vais enfin indiquer avec PDO::PARAM le type que je veux/dois recevoir ( _STR pour une chaine de caractères, _INT pour un entier )

bindValue permet d'associer une valeur à un parametre. Il n'est pas obligatoire. D'autres lui préfèreront bindParam. C'est un choix, et les deux syntaxes sont assez ressemblantes

Une fois terminé tous mes bindValue, il ne me reste qu'à exécuter ma requete

En dernier lieu, et c'est peut-etre ce qu'il y a de plus simple a faire (au sens rapide), il faudra se prémunir des failles XSS. Il s'agira pour la personne malveillante, comme pour les injections SQL, d'envoyer du code via un formulaire ou l'URL, pour affecter directement le site (et non plus la BDD)

Pour cela, j'ai à ma disposition deux fonctions sur les chaines de caractères. Il s'agit de htmlspecialchars et de strip_tags, même si d'autres existent pour cela

Je remets, pour la démonstration, le formulaire du chapitre sur la méthode POST

Si je décide de mettre ce simple bout de code CSS dans l'input destiné a recevoir le pseudo ou la description


<style>
body{
  display:none;
}
</style>

La page entière va disparaitre. En fait, elle sera juste invisible, du fait du display:none (vérifiez en affichant le code source de la page). Tout est encore là, mais inexploitable désormais.

Faites le test. Vous n'aurez ensuite qu'à fermer l'onglet de cette page, puis recharger le cours. Tout le contenu sera à nouveau disponible

Tout reviendra comme avant, mais si ce formulaire avait été validé pour aller alimenter la BDD, les dégats auraient été plus long a réparer

En premier lieu, cela aurait affecté tous les utilisateurs. Puis il aurait fallu aller nettoyer la base de données pour retrouver la situation initiale. Et ce, avec juste un bout de code que je vais qualifier de "gentil".

Faites un autre test, en réinjectant le même code dans le formulaire du chapitre de la méthode POST. Ce dernier, étant protégé contrairement a celui ci, ne fera que retranscrire le code comme une chaine de caractères lambda

résultat avec un formulaire protégé par htmlspecialchars()

Voici la syntaxe pour le protéger, très simple


<li class="list-group-item">
  <h5>Prénom: <?= htmlspecialchars($_POST['prenom']) ?></h5>
</li>
<li class="list-group-item">
  <h5>Description: <?= htmlspecialchars($_POST['description']) ?></h5>
</li>

Et, pour éviter de consacrer trop de temps à protéger chaque parametre, chaque formulaire (car en regle générale, il y en aura plusieurs sur un site), je vais créer deux boucles, dont la syntaxe tiendra pour chacunes sur deux lignes

La première pour les formulaires, la seconde pour toutes les infos que j'aurai a faire transiter via l'URL

Ces deux boucles, je vais les scripter dans un fichier (que je nommerais, par exemple, init.php), que j'appelerai ensuite avec un require_once sur toutes les pages de mon site.


foreach($_POST as $key =>$value){
  $_POST[$key] = htmlspecialchars(trim($value));
}

foreach($_GET as $key =>$value){
  $_GET[$key] = htmlspecialchars(trim($value));
}

En plus de htmlspecialchars je vais proteger l'envoi de données avec trim

Cette dernière sert à supprimer les espaces (ou caractères invisibles) en début et fin de chaque donnée envoyée

Pour aller un peu plus loin, concernant init.php (pour initialisation), c'est le fichier dans lequel je scripterai la connection à ma base de données ( $pdo = new PDO() etc... , chapitre 20 consacré à PDO ). J'appelerai mon session_start (chapitre 19 sur les sessions) et declarerai diverses variables et constantes dont j'aurai besoin ultérieurement pour les fonctionnalités de mon site.

Pour terminer ce chapitre, je vais aborder un aspect légèrement différent, mais toujours en relation avec la sécurité d'un site

Il ne s'agira plus de proteger le transit de données, mais de gérer les autorisations des visiteurs et utilisateurs sur les différentes pages du site.

De manière basique, je vais devoir proteger le back-office de toute personne qui n'aura aucune habilitation à y faire quoique ce soit. Seul un administrateur, ou par extention l'équipe administrative, pourra se rendre sur ces pages "sensibles"

Un des moyens que j'ai a ma disposition, c'est la redirection de toute personne non-autorisée, vers une page moins sensible du site

Pour cela, je vais faire appel à la fonction réseaux : header() , avec l'entete (location:) , suivi de l'url de la page où je veux l'envoyer

Voici par exemple un script pour "renvoyer" toute personne non-autorisée d'une des pages du back-office


if(!internauteConnecteAdmin()){
  header('location:../connexion.php');
  exit();
}

Ma condition de départ sera : si internaute connecté n'est pas admin (avec le "not" ! ).C'est une fonction d'utilisateur que j'ai codé au préalable, et ses droits seront plus restrictifs que internauteConnecte(), fonction pour des utilisateurs basiques

Donc, si l'internaute ne possède pas des droits d'admin, alors il sera rebasculé automatiquement vers la page de connexion

Je ne le renvoie pas vers sa page de profil, car en fait je ne sais même pas si c'est quelqu'un qui est connecté. Il est malheureusement probable que ce soit un individu mal-intentionné, qui entre une URL hypothétique dans la barre d'adresse. Je l'envoie vers la page de connexion du site, et s'il était déja connecté, une autre redirection le rebasculera automatiquement vers son profil

Il faut veiller à proteger chaque page selon le statut de l'internaute

Dans le même ordre d'idées, s'il est déjà connecté, alors je lui interdirai d'aller sur les pages d'inscription ou de connexion. Il n'a rien à y faire, il est déjà connecté !

Inversement, la page profil sera "interdite" pour quelqu'un de non-connecté

Dernier point concernant les redirections : je peux en faire une pour une inscription réussie, vers la page connexion. Puis une autre vers la page profil pour une connexion réussie, avec à chaque fois un message pour en informer le user

Dans ce cas là, il ne sera pas question de sécurité, mais la volonté d'améliorer l'expérience utilisateur (UX), de lui rendre sa navigation agréable et plus facile