Sécuriser une application PHP : les contrôles utiles

Verdict : Pour une application PHP qui reçoit des formulaires, des comptes ou des fichiers, commencez par les frontières : données validées, requêtes paramétrées, sessions protégées et stockage hors du web. Les réglages PHP renforcent ce socle.
Sommaire
- Réduire les pouvoirs du processus web
- Une entrée utilisateur n’est jamais un chemin fiable
- Les comptes ne stockent pas les mots de passe
- La session doit rester un cookie opaque
- La base de données doit distinguer code et données
- Un fichier téléversé reste une donnée hostile
- Les erreurs vont dans les journaux, pas dans la réponse
- Mesurer ce que reçoit le navigateur
- La checklist qui tient dans une revue
PHP peut lire des fichiers, appeler des commandes et ouvrir des connexions réseau lorsqu’il s’exécute derrière un serveur web. La documentation PHP sur la sécurité le rappelle dès son introduction : la configuration et le code doivent être pensés ensemble.
Réduire les pouvoirs du processus web
Le compte utilisé par PHP n’a pas besoin d’administrer le serveur. Donnez-lui l’accès aux seuls répertoires que l’application doit lire ou modifier, puis retirez les droits d’écriture ailleurs. Un formulaire qui enregistre une photo n’a pas besoin d’écrire dans le dossier qui contient les scripts.
Cette séparation rend une erreur plus difficile à transformer en prise de contrôle. Elle concerne le système de fichiers, la base de données et les commandes externes. Un compte SQL dédié à l’application ne devrait pas être propriétaire de toutes les bases : une page de lecture peut fonctionner avec des droits de lecture, tandis qu’une migration appartient à un compte d’administration séparé.
Le réglage open_basedir peut ajouter une frontière côté PHP. Les permissions du système restent indispensables. La documentation PHP sur la sécurité des fichiers montre pourquoi un chemin construit avec des valeurs reçues du navigateur peut finir par viser un fichier qui n’était pas prévu.
Une entrée utilisateur n’est jamais un chemin fiable
Chaque valeur externe doit être associée à une forme attendue : entier, identifiant connu, adresse électronique, texte court ou choix dans une liste. filter_input() ne filtre rien par défaut lorsqu’il utilise FILTER_DEFAULT, qui est un alias de FILTER_UNSAFE_RAW. Choisissez donc explicitement le filtre et traitez séparément l’absence de valeur et l’échec de validation.
<?php
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if ($id === false || $id === null) {
http_response_code(400);
exit('Identifiant invalide');
}
$sort = filter_input(INPUT_GET, 'sort', FILTER_UNSAFE_RAW);
$sortMap = [
'recent' => 'created_at DESC',
'name' => 'name ASC',
];
if (!is_string($sort) || !isset($sortMap[$sort])) {
$sort = 'recent';
}
// $sortMap[$sort] vient du code, jamais de la requête.
La validation décide ce que l’application accepte. L’échappement intervient ensuite, au moment de produire du HTML, une URL ou une requête shell. Pour du texte affiché dans une page, htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') est une base lisible. La fiche PHP de filter_input() précise aussi que la fonction lit la valeur brute fournie par le SAPI, pas une superglobale que le code aurait modifiée.
Les comptes ne stockent pas les mots de passe
Un mot de passe se vérifie à partir d’un hachage lent, pas à partir d’un chiffrement réversible ni d’une empreinte rapide. password_hash() génère automatiquement un sel et place dans le résultat les informations nécessaires à password_verify(). Ne fabriquez pas le sel vous-même.
<?php
$hash = password_hash($password, PASSWORD_DEFAULT);
if (password_verify($passwordAttempt, $hash)) {
session_regenerate_id(true);
$_SESSION['user_id'] = $userId;
}
La documentation actuelle recommande une colonne capable de contenir jusqu’à 255 octets, car l’algorithme par défaut peut évoluer. Elle indique aussi que le coût bcrypt par défaut est passé à 12 avec PHP 8.4. Utilisez password_needs_rehash() lors d’une connexion réussie pour migrer progressivement un hachage ancien vers les paramètres retenus pour la machine.
Le manuel de password_hash() fournit un petit benchmark à exécuter sur l’environnement qui servira les connexions. Un coût correct est celui qui reste compatible avec la latence attendue de votre application, pas une valeur copiée depuis un autre serveur.
La session doit rester un cookie opaque
Après une authentification réussie, renouvelez l’identifiant de session avant d’y placer les droits du compte. Configurez le module pour accepter les identifiants par cookie uniquement et refusez les identifiants que PHP n’a pas générés.
; php.ini ou configuration du pool PHP
session.use_cookies = 1
session.use_only_cookies = 1
session.use_strict_mode = 1
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax
session.cookie_lifetime = 0
session.use_trans_sid = 0
cookie_secure suppose que toute la session passe en HTTPS. cookie_httponly empêche un script du navigateur de lire le cookie ; SameSite=Lax ou Strict réduit l’envoi dans des contextes intersites. Ajoutez un jeton CSRF distinct pour les actions qui modifient un compte, une commande ou une ressource.
Le manuel PHP des configurations de session explique notamment le rôle de session.use_strict_mode, de session.cookie_secure et de la durée de vie nulle. OWASP détaille aussi la gestion des identifiants dans sa fiche sur les sessions.
La base de données doit distinguer code et données
Une donnée reçue par formulaire ne doit jamais être collée dans le texte SQL. Préparez la requête et transmettez la valeur séparément avec PDO.
<?php
$stmt = $pdo->prepare(
'SELECT id, email FROM users WHERE email = :email'
);
$stmt->execute(['email' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
Les paramètres protègent les valeurs. Ils ne servent pas à choisir directement un nom de colonne, un sens de tri ou un nom de table. Pour ces éléments, mappez une valeur courte vers une liste écrite dans le code, comme dans l’exemple de tri plus haut. La fiche OWASP sur les injections SQL place les requêtes paramétrées avant l’échappement manuel, qui dépend du moteur et du contexte.
Un fichier téléversé reste une donnée hostile
Ne reprenez ni le nom transmis par le navigateur ni son type MIME déclaré. Définissez les formats réellement utiles, imposez une taille et inspectez le contenu avec finfo. Donnez au fichier un nom généré par l’application et stockez-le hors du répertoire servi par le serveur web.
<?php
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
];
if (!isset($_FILES['avatar']['tmp_name']) ||
!is_uploaded_file($_FILES['avatar']['tmp_name']) ||
$_FILES['avatar']['size'] > 2 * 1024 * 1024) {
throw new RuntimeException('Fichier refusé');
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['avatar']['tmp_name']);
if (!isset($allowed[$mime])) {
throw new RuntimeException('Format refusé');
}
$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$destination = '/srv/app-private/uploads/' . $name;
if (!move_uploaded_file($_FILES['avatar']['tmp_name'], $destination)) {
throw new RuntimeException('Enregistrement impossible');
}
Servez ensuite le fichier par une route qui vérifie le compte autorisé et choisit le chemin à partir d’un identifiant interne. Cette approche évite qu’un nom comme ../config.php devienne une partie d’un chemin de suppression ou de lecture. La documentation PHP sur les chargements rappelle de vérifier la structure de $_FILES et les erreurs d’upload ; OWASP rassemble les contrôles complémentaires dans sa fiche de protection des fichiers.
Les erreurs vont dans les journaux, pas dans la réponse
En production, le navigateur doit recevoir une réponse sobre lorsque le code rencontre une exception. Activez les journaux, désactivez l’affichage détaillé et vérifiez que le fichier de log n’est pas accessible depuis le web.
; configuration de production
expose_php = Off
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php/app-error.log
Le message détaillé appartient au journal et doit rester associé à un identifiant de corrélation non sensible. La réponse publique peut afficher un identifiant de support et l’action à effectuer, sans renvoyer un chemin local, une requête SQL, un nom de classe ou une trace d’appel.
Pour la base Linux, voyez aussi le guide sur le durcissement minimal, puis contrôlez les droits avec le tutoriel sur les permissions et utilisateurs. Les journaux PHP prennent leur valeur lorsque leur rotation, leur conservation et leur alerte sont réellement configurées ; le guide des services et journaux systemd aide à traiter cette partie côté serveur.
Mesurer ce que reçoit le navigateur
Un contrôle d’en-têtes donne un premier signal rapide avant une revue de code. Cette commande compte six noms attendus et affiche ceux présents dans la réponse :
curl -sS -D - -o /dev/null https://www.php.net/manual/fr/security.intro.php \
| python3 -c 'import sys; names={"strict-transport-security","content-security-policy","x-frame-options","x-content-type-options","referrer-policy","permissions-policy"}; hits=[line.split(":",1)[0].lower() for line in sys.stdin.read().splitlines() if ":" in line and line.split(":",1)[0].lower() in names]; print(len(hits), "en-têtes de sécurité présents:", ", ".join(hits))'
Exécutée le 18 septembre 2026, cette commande a trouvé 3 en-têtes sur la documentation PHP : strict-transport-security, permissions-policy et x-frame-options. La mesure porte sur la réponse HTTP observée ; la revue du code et des droits vient ensuite.
Sur votre domaine, remplacez l’URL par la page d’authentification puis par une page publique. Vérifiez aussi les attributs des cookies, les redirections HTTP vers HTTPS et les réponses produites par une erreur. Ces contrôles complètent la validation des données et le contrôle d’accès.
La checklist qui tient dans une revue
- Le processus PHP écrit uniquement dans les répertoires prévus et utilise un compte SQL limité.
- Les entrées ont un type attendu ; les choix dynamiques passent par une liste écrite dans le code.
- Les sorties sont encodées dans leur contexte et les requêtes utilisent des paramètres PDO.
- Les mots de passe passent par
password_hash(), puispassword_verify()et une migration avecpassword_needs_rehash(). - Les sessions utilisent des cookies sécurisés, un mode strict et un renouvellement après connexion.
- Les fichiers sont contrôlés par taille et contenu, renommés et conservés hors du web.
- Les erreurs détaillées vont dans un journal protégé, avec rotation et alerte.
Pour un projet déjà en ligne, prenez une route à la fois : formulaire, connexion, téléchargement, consultation d’un document puis action d’administration. À chaque étape, suivez la donnée depuis la requête jusqu’à son stockage ou son affichage. Vous trouverez plus vite la frontière manquante qu’en ajoutant une option PHP au hasard.