Dès qu’une application PHP est exposée au web, elle devient une cible. Les trois attaques les plus courantes — injection SQL, XSS et CSRF — ne sont pas des menaces théoriques : elles figurent en tête des incidents réels, et leurs parades sont parfaitement connues. Encore faut-il les appliquer partout, sans exception. Dans ce tutoriel, on durcit le projet « Atelier » : requêtes préparées, échappement à l’affichage, jetons anti-CSRF, hachage des mots de passe selon l’état de l’art, et configuration sûre. L’objectif est qu’à la fin, l’espace d’administration de l’inventaire résiste aux attaques classiques.
Article de référence : ce tutoriel fait partie du guide complet du PHP moderne. Il prolonge des sujets déjà traités : sécuriser un formulaire PHP contre les injections SQL et hacher des mots de passe avec bcrypt et argon2.
Ce que vous allez apprendre
- Bloquer l’injection SQL avec des requêtes préparées, sans exception.
- Neutraliser le XSS en échappant systématiquement à l’affichage.
- Protéger les formulaires avec un jeton anti-CSRF comparé en temps constant.
- Hacher et vérifier les mots de passe avec les fonctions dédiées de PHP 8.4.
- Configurer les sessions et les en-têtes HTTP pour réduire la surface d’attaque.
Ce que vous allez construire
Un formulaire d’administration sécurisé pour « Atelier » : connexion par mot de passe haché, ajout de pièce protégé contre le CSRF, affichage de l’inventaire sans faille XSS, le tout sur une couche d’accès aux données déjà préparée. Une mini-forteresse autour des opérations sensibles.
Prérequis
- PHP 8.4, le projet « Atelier » avec sa couche PDO.
- Notions de formulaires HTML et de sessions PHP.
- Avoir lu le tutoriel sur les erreurs et exceptions aide à ne rien fuiter en cas d’incident.
- ⏱️ Temps estimé : ~50 minutes.
Étape 1 — Injection SQL : la règle d’or des requêtes préparées
L’injection SQL consiste à glisser du code SQL dans une donnée que l’application va coller dans une requête. Si une recherche de pièce construit son SQL par concaténation, un attaquant qui saisit ' OR '1'='1 peut détourner la requête, voire la faire supprimer la table. La parade est unique et absolue : ne jamais concaténer de donnée dans du SQL, toujours passer par une requête préparée à paramètres liés.
<?php
// JAMAIS — vulnérable à l'injection
$ref = $_GET['ref'];
$sql = "SELECT * FROM pieces WHERE reference = '$ref'"; // catastrophe
// TOUJOURS — requête préparée
$stmt = $pdo->prepare('SELECT * FROM pieces WHERE reference = :ref');
$stmt->execute(['ref' => $_GET['ref'] ?? '']);
$piece = $stmt->fetch();
Dans la version sûre, la valeur saisie n’est jamais interprétée comme du SQL : MySQL la traite comme une donnée littérale, même si elle contient des apostrophes ou des mots-clés SQL. C’est ce que le tutoriel PDO a posé comme fondation, et c’est non négociable. Un détail important : on ne peut pas lier le nom d’une colonne ou d’une table par paramètre — seulement des valeurs. Si vous devez trier par une colonne choisie par l’utilisateur, validez ce nom contre une liste blanche fixe, jamais en l’injectant directement.
Étape 2 — XSS : échapper systématiquement à l’affichage
Le XSS (cross-site scripting) est le miroir de l’injection SQL, côté navigateur. Si l’application affiche une donnée fournie par l’utilisateur sans la neutraliser, un attaquant peut y placer du <script> qui s’exécutera chez les autres visiteurs. La défense : échapper toute donnée à l’affichage avec htmlspecialchars(), qui transforme les caractères dangereux (<, >, &, guillemets) en entités HTML inoffensives.
<?php
// Fonction utilitaire d'échappement, à utiliser partout en sortie
function e(string $valeur): string
{
return htmlspecialchars($valeur, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
?>
<table>
<?php foreach ($depot->toutes() as $piece): ?>
<tr>
<td><?= e($piece->reference) ?></td>
<td><?= e($piece->nom) ?></td>
<td><?= e($piece->categorie->libelle()) ?></td>
</tr>
<?php endforeach ?>
</table>
Le drapeau ENT_QUOTES échappe à la fois les guillemets simples et doubles, indispensable quand la donnée se retrouve dans un attribut HTML ; on précise toujours l’encodage UTF-8 pour éviter les contournements liés au jeu de caractères. La règle pratique : on stocke les données brutes (telles que saisies) et on échappe seulement au moment de l’afficher. Échapper trop tôt, à l’enregistrement, mène à des données doublement échappées et illisibles. Un petit alias e() rend l’échappement systématique sans alourdir le code.
Point d’étape — Ajoutez une pièce dont le nom contient
<b>test</b>, puis affichez l’inventaire. Vous devez voir le texte littéral<b>test</b>, pas du gras. Si le mot apparaît en gras, c’est que l’échappement manque à cet endroit — chaque sortie de donnée doit passer pare().
Étape 3 — Sessions sûres pour l’administration
L’espace d’administration suppose une session. Une session mal configurée est elle-même une faille : un cookie de session lisible en JavaScript ou transmis en clair peut être volé. Avant de démarrer la session, on règle ses paramètres de cookie : httponly (inaccessible au JavaScript), secure (transmis uniquement en HTTPS) et samesite (limite l’envoi inter-sites, première barrière contre le CSRF).
<?php
session_set_cookie_params([
'httponly' => true,
'secure' => true, // exige HTTPS
'samesite' => 'Strict', // cookie non envoyé depuis un autre site
'path' => '/',
]);
session_start();
// Après une connexion réussie, régénérer l'identifiant de session
function ouvrirSession(int $idAdmin): void
{
session_regenerate_id(true); // contre la fixation de session
$_SESSION['admin_id'] = $idAdmin;
}
L’appel à session_regenerate_id(true) juste après la connexion est crucial : il change l’identifiant de session, ce qui déjoue l’attaque par fixation de session où un attaquant impose à la victime un identifiant qu’il connaît. Les trois drapeaux de cookie, eux, réduisent les vecteurs de vol. Ces réglages ne coûtent rien à mettre en place et ferment plusieurs portes d’un coup.
Étape 4 — CSRF : un jeton par formulaire
Le CSRF (cross-site request forgery) exploite la confiance du serveur envers le navigateur connecté : un site malveillant fait soumettre, à l’insu de la victime déjà authentifiée, une requête vers votre application (par exemple supprimer une pièce). La parade : exiger un jeton secret, propre à la session, que seul votre formulaire connaît. Une requête forgée par un autre site ne pourra pas le fournir.
<?php
// Générer (ou réutiliser) un jeton pour la session
function jetonCsrf(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
// Vérifier le jeton reçu, en temps constant
function verifierCsrf(?string $recu): bool
{
return is_string($recu)
&& isset($_SESSION['csrf'])
&& hash_equals($_SESSION['csrf'], $recu);
}
Le jeton est généré avec random_bytes(), source cryptographiquement sûre — jamais rand() ni uniqid(), prévisibles. La vérification utilise hash_equals(), qui compare en temps constant : contrairement à ==, elle ne révèle pas, par sa durée, combien de caractères correspondent, ce qui empêche une attaque temporelle. On insère le jeton dans chaque formulaire d’écriture, en champ caché, et on le vérifie à la réception.
<!-- Dans le formulaire d'ajout -->
<form method="post" action="ajouter.php">
<input type="hidden" name="csrf" value="<?= e(jetonCsrf()) ?>">
<input name="reference" required>
<button>Ajouter</button>
</form>
<?php
// À la réception (ajouter.php)
if (!verifierCsrf($_POST['csrf'] ?? null)) {
http_response_code(403);
exit('Requête refusée.');
}
// ... traitement de l'ajout, protégé ...
Point d’étape — Soumettez le formulaire normalement : l’ajout doit passer. Puis modifiez la valeur du champ
csrfdans les outils du navigateur et resoumettez : vous devez obtenir un 403 « Requête refusée ». Si l’ajout passe malgré un jeton modifié, la vérification n’est pas branchée sur le bon traitement.
Étape 5 — Mots de passe : hacher, jamais chiffrer ni stocker en clair
Un mot de passe ne se stocke jamais en clair, ni avec MD5 ou SHA-1 : ces algorithmes sont rapides, donc faciles à attaquer par force brute, et n’ont jamais été conçus pour les mots de passe. PHP fournit des fonctions dédiées qui appliquent un algorithme lent et salé automatiquement. On crée le hachage avec password_hash() et on le vérifie avec password_verify().
<?php
// À la création du compte administrateur
$hash = password_hash($motDePasseClair, PASSWORD_DEFAULT);
// → on stocke $hash en base (colonne VARCHAR(255)), jamais le mot de passe
// À la connexion
function verifierAdmin(string $saisi, string $hashStocke): bool
{
return password_verify($saisi, $hashStocke);
}
PASSWORD_DEFAULT sélectionne le meilleur algorithme disponible — actuellement bcrypt. Un point important propre à PHP 8.4 : le coût par défaut de bcrypt est passé de 10 à 12, rendant chaque hachage plus lent à calculer, donc plus coûteux à attaquer. password_hash() gère le sel automatiquement (inutile et déconseillé d’en ajouter un manuel), et password_verify() sait extraire le sel et le coût du hachage stocké pour comparer. Le hachage résultant inclut l’algorithme, le coût et le sel : tout est dans la chaîne enregistrée.
Pour une sécurité accrue, on peut choisir explicitement Argon2id, lauréat de la compétition de hachage de mots de passe, via PASSWORD_ARGON2ID (disponible si PHP a été compilé avec le support Argon2). Le tutoriel dédié au hachage avec bcrypt et argon2 compare les deux en détail.
Étape 6 — Rehacher au bon moment
Comme le coût par défaut évolue (8.4 l’a augmenté), un hachage créé hier peut être « trop faible » aujourd’hui. PHP prévoit password_needs_rehash() : à chaque connexion réussie, on vérifie si le hachage stocké correspond toujours aux paramètres actuels, et sinon on le recalcule de façon transparente. L’utilisateur ne voit rien ; la base se met à niveau au fil des connexions.
<?php
function connecter(string $saisi, array $admin, PDO $pdo): bool
{
if (!password_verify($saisi, $admin['mot_de_passe'])) {
return false;
}
// Le coût a-t-il changé depuis le dernier hachage ?
if (password_needs_rehash($admin['mot_de_passe'], PASSWORD_DEFAULT)) {
$nouveau = password_hash($saisi, PASSWORD_DEFAULT);
$stmt = $pdo->prepare('UPDATE admins SET mot_de_passe = :h WHERE id = :id');
$stmt->execute(['h' => $nouveau, 'id' => $admin['id']]);
}
return true;
}
Ce mécanisme est précieux après une mise à jour de PHP : tous les comptes migrent progressivement vers le coût plus élevé, sans réinitialisation forcée des mots de passe. On a accès au mot de passe en clair seulement à ce moment précis (juste après password_verify), ce qui est l’unique instant légitime pour le rehacher. C’est une bonne illustration de sécurité qui s’entretient dans le temps, pas seulement à l’installation.
Étape 7 — En-têtes HTTP et configuration défensive
Quelques en-têtes HTTP ajoutent une couche de défense côté navigateur, et quelques réglages serveur réduisent encore la surface. On envoie ces en-têtes avant toute sortie. Ils ne remplacent pas le code sûr, mais ils limitent l’impact d’une faille résiduelle.
<?php
header('X-Content-Type-Options: nosniff'); // pas de devinette de type MIME
header('X-Frame-Options: DENY'); // pas d'inclusion en iframe (anti-clickjacking)
header('Referrer-Policy: same-origin');
header("Content-Security-Policy: default-src 'self'"); // limite les sources de scripts
La Content-Security-Policy est la plus puissante : en restreignant les sources autorisées de scripts, elle réduit drastiquement l’impact d’un XSS résiduel, car un script injecté depuis un domaine externe sera bloqué par le navigateur. Côté configuration, on complète avec les réglages vus dans le tutoriel sur les erreurs : display_errors à 0 en production pour ne rien fuiter. Enfin, on s’assure que les fichiers sensibles (.env, dossier vendor/, fichiers de configuration) ne sont pas servis directement par le serveur web — on les place hors de la racine publique ou on les bloque dans la configuration du serveur.
Étape 8 — Vérification finale
Passez en revue l’espace d’administration complet : connexion par mot de passe haché avec rehachage automatique, session aux cookies durcis et identifiant régénéré, formulaires protégés par jeton anti-CSRF comparé en temps constant, affichage entièrement échappé, accès aux données par requêtes préparées, et en-têtes HTTP défensifs. Tentez chaque attaque sur votre propre application : injection dans un champ, balise <script> dans un nom, soumission sans jeton, mot de passe en clair dans la base. Aucune ne doit aboutir. Quand votre inventaire résiste à ces tests, vous tenez une application PHP réellement durcie.
Pièges fréquents
| Symptôme / faille | Cause probable | Correctif |
|---|---|---|
| Injection SQL possible | Donnée concaténée dans le SQL | Requête préparée à paramètres liés, sans exception |
| Script exécuté dans la page | Donnée affichée sans échappement | htmlspecialchars() à chaque sortie |
| Formulaire soumis depuis un autre site | Pas de jeton CSRF | Jeton par session + hash_equals() |
| Mots de passe lisibles après fuite | MD5/SHA-1 ou stockage en clair | password_hash() / password_verify() |
Comparaison de jeton avec == |
Vulnérable aux attaques temporelles | Toujours hash_equals() |
| Cookie de session volé en JavaScript | Drapeau httponly absent |
Configurer les paramètres de cookie avant session_start() |
Réalités du terrain
Le HTTPS n’est plus une option : sans lui, le drapeau secure du cookie n’a pas de sens et les mots de passe transitent en clair. La quasi-totalité des hébergeurs proposent aujourd’hui un certificat gratuit Let’s Encrypt activable en un clic ; il n’y a aucune raison de s’en passer, même pour un petit projet. Côté configuration, beaucoup d’hébergements mutualisés laissent par défaut display_errors actif et exposent des fichiers sensibles : prenez le réflexe de vérifier ces points dès la mise en ligne, via un fichier de configuration du serveur. Enfin, gardez PHP à jour : chaque version corrige des failles, et rester sur une version en fin de vie revient à laisser des portes ouvertes connues. Mettre à jour de 8.1 vers 8.4 dans le panneau de l’hébergeur, après avoir testé, fait partie de l’hygiène de sécurité de base.
Récapitulatif
Vous avez transformé « Atelier » en une application qui se défend. Les requêtes préparées ferment l’injection SQL ; l’échappement à l’affichage neutralise le XSS ; les jetons anti-CSRF comparés en temps constant protègent les écritures ; password_hash() et le rehachage tiennent les mots de passe à l’état de l’art, avec le nouveau coût bcrypt de PHP 8.4 ; les sessions durcies et les en-têtes HTTP réduisent la surface restante. Aucune de ces défenses n’est compliquée — la difficulté est de les appliquer partout, sans oubli. C’est cette discipline, plus que des techniques exotiques, qui fait une application sûre.
Aide-mémoire
| Élément | Rôle |
|---|---|
$pdo->prepare() + paramètres liés |
Anti-injection SQL |
htmlspecialchars($v, ENT_QUOTES, 'UTF-8') |
Anti-XSS à l’affichage |
random_bytes(32) + champ caché |
Génération du jeton CSRF |
hash_equals($a, $b) |
Comparaison en temps constant |
password_hash($p, PASSWORD_DEFAULT) |
Hachage de mot de passe (bcrypt coût 12 en 8.4) |
password_needs_rehash() |
Rehachage quand le coût évolue |
session_regenerate_id(true) |
Anti-fixation de session |
Content-Security-Policy |
Limite l’impact d’un XSS résiduel |
À vous de jouer
Ajoutez une limitation des tentatives de connexion : après cinq échecs consécutifs pour un même compte, bloquez les essais pendant quelques minutes. Stockez le compteur et l’horodatage en session ou en base.
Voir une solution
$cle = 'tentatives_' . $login;
$_SESSION[$cle] ??= ['n' => 0, 'jusqu' => 0];
if (time() < $_SESSION[$cle]['jusqu']) {
exit('Trop de tentatives. Réessayez plus tard.');
}
if (!password_verify($saisi, $hash)) {
if (++$_SESSION[$cle]['n'] >= 5) {
$_SESSION[$cle] = ['n' => 0, 'jusqu' => time() + 300];
}
exit('Identifiants invalides.');
}
$_SESSION[$cle]['n'] = 0; // succès : on réinitialise
Tutoriels liés
- Accéder à MySQL avec PDO — la couche de données et ses requêtes préparées.
- La gestion des erreurs et des exceptions — ne rien fuiter quand une attaque échoue.
Pour aller plus loin
- 🔝 Retour au guide : PHP moderne, le guide complet.
- Référentiel des risques applicatifs : OWASP Top 10.
- Sécurité dans la doc officielle : php.net — Sécurité.
- Le hachage des mots de passe : php.net — password_hash.
Foire aux questions
Faut-il échapper les données à l’enregistrement ou à l’affichage ?
À l’affichage. On stocke les données telles que saisies (en se protégeant de l’injection SQL par les requêtes préparées) et on échappe seulement au moment de produire le HTML, avec htmlspecialchars(). Échapper à l’enregistrement double les entités et corrompt les données dès qu’on les réutilise ailleurs qu’en HTML.
bcrypt ou Argon2id pour les mots de passe ?
Les deux sont sûrs. bcrypt (via PASSWORD_DEFAULT) est universellement disponible et, avec le coût 12 de PHP 8.4, très solide. Argon2id résiste mieux aux attaques matérielles si l’extension est présente. Pour un projet standard, PASSWORD_DEFAULT est un excellent choix par défaut ; Argon2id se justifie pour des exigences élevées.
Un jeton CSRF par session ou par formulaire ?
Un jeton par session suffit dans la plupart des cas et simplifie le code. Des jetons par formulaire (à usage unique) offrent une protection un cran au-dessus contre certains scénarios, au prix d’une gestion plus complexe. Commencez par un jeton de session bien vérifié avec hash_equals() ; c’est déjà une protection robuste.